diff --git a/CLAUDE.md b/CLAUDE.md index a2813bd..8727d1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ Quick reference for AI assistants working on this project. ## Dead Code Annotations -The codebase contains **29 intentional `#[allow(dead_code)]` annotations** marking Phase 3+ features that are: +The codebase contains **22 intentional `#[allow(dead_code)]` annotations** marking Phase 3+ features that are: - Fully tested and ready for future implementation - Strategic placeholders for planned features (analytics, export, multi-layout support) - Preserved to avoid reimplementation work @@ -38,10 +38,8 @@ These are NOT technical debt - they represent well-architected future functional - Frequency Data (4): Linguistic research data for weighted practice - Code Categorization (2): SymbolCategory for future UI filtering - Keyboard Abstraction (3): AzertyLayout for multi-layout support -- UI Placeholders (3): CursorWindow fields for scrolling/pagination - Test Utilities (3): Reusable drill generation patterns - Adaptive Features (2): WeaknessDetector (fully tested, ready for UI) -- Scoring Utilities (1): calculate_accuracy() reference implementation - Custom Lesson Validation (1): ParseError::InvalidFrontMatter for future strict YAML validation ## Project Overview @@ -67,7 +65,7 @@ This project uses feature-based documentation. See [docs/README.md](docs/README. | Visual Keyboard | `src/ui/keyboard.rs` | ✅ Phase 3.6 (with AltGr) | | Finger Training | `src/content/finger_generator.rs` | ✅ Phase 3.2 | | Two-Level Menu | `src/content/category.rs`, `app.rs` | ✅ Phase 3.3 | -| Statistics Dashboard | `src/ui/render.rs` | ✅ Phase 3.5 | +| Statistics Dashboard | `src/ui/render/statistics.rs` | ✅ Phase 3.5 | | Custom Lessons | `src/content/custom.rs` | ✅ Phase 3.7 | See [docs/README.md#features-overview](docs/README.md#features-overview) for detailed feature descriptions. @@ -77,7 +75,7 @@ See [docs/README.md#features-overview](docs/README.md#features-overview) for det ```bash # Development cargo run # Launch application -cargo test # Run test suite (145 tests) +cargo test # Run test suite (186 tests) cargo check # Fast compilation check # Testing Adaptive Mode @@ -99,7 +97,7 @@ git push origin main && git push origin v0.8.0 # Push to trigger automated ## CI/CD Workflows ### Continuous Integration -Runs on every push to `main` and all PRs: formatting, linting, tests (145 passing), security audit. +Runs on every push to `main` and all PRs: formatting, linting, tests (186 passing), security audit. ### Release Automation Triggers on git tag `v*.*.*`: @@ -134,7 +132,8 @@ src/ ├── main.rs # Entry point ├── app.rs # App state machine, two-level navigation, event loop ├── ui/ # TUI rendering -│ ├── render.rs # Category menu, lesson menu, layout rendering +│ ├── render/ # Rendering split by screen (session, menu, results, statistics, analytics_screen) +│ ├── analytics.rs # Pure chart/goal aggregation helpers │ └── keyboard.rs # Visual keyboard display ├── engine/ # Session logic, scoring, analytics │ ├── analytics.rs # Per-key/bigram statistics tracking @@ -149,6 +148,7 @@ src/ │ ├── custom.rs # User-provided markdown lessons │ ├── finger_generator.rs # Finger-based drills │ ├── lesson.rs # Lesson types, definitions +│ ├── ngram_generator.rs # Shared bigram/trigram drill logic (DrillItem trait) │ └── generator.rs # Home row drills ├── data/ # Stats persistence (with adaptive analytics) └── keyboard/ # AZERTY layout and data model @@ -184,7 +184,7 @@ See [docs/features/session-storage/](docs/features/session-storage/) for complet **Phase**: 3.7 Complete (Custom Lessons) **Next**: Phase 3+ (Analytics visualization, data export, gamification) -**Tests**: 145 passing +**Tests**: 186 passing **Lessons**: 60 built-in + user custom lessons See [docs/README.md#project-status](docs/README.md#project-status) for complete roadmap. diff --git a/src/app.rs b/src/app.rs index d3bcdbb..d7065e6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -222,8 +222,7 @@ impl App { ui::render_analytics_export(f, &self.stats, self.export_message.as_deref()); } AppState::LessonMenu => { - let filtered_lessons: Vec<_> = - self.filtered_lessons().into_iter().cloned().collect(); + let filtered_lessons = self.filtered_lessons(); let category_name = self.current_category.and_then(|ct| { self.categories @@ -412,8 +411,8 @@ impl App { } } KeyCode::Down | KeyCode::Char('j') => { - if self.selected_lesson < filtered_count - 1 { - self.selected_lesson += 1; + if let Some(next) = next_menu_index(self.selected_lesson, filtered_count) { + self.selected_lesson = next; // Scroll down if selection goes below viewport (using conservative estimate of 20) let viewport_height = 20; if self.selected_lesson >= self.lesson_scroll_offset + viewport_height { @@ -600,16 +599,20 @@ impl App { /// Get lessons filtered by current category fn filtered_lessons(&self) -> Vec<&Lesson> { if let Some(category_type) = self.current_category { - let category = self + // A state desync (category not in the list) degrades to an empty menu + // rather than panicking mid-session. + match self .categories .iter() .find(|c| c.category_type == category_type) - .expect("Current category must exist"); - - self.lessons - .iter() - .filter(|lesson| category.contains_lesson(lesson)) - .collect() + { + Some(category) => self + .lessons + .iter() + .filter(|lesson| category.contains_lesson(lesson)) + .collect(), + None => Vec::new(), + } } else { Vec::new() } @@ -659,6 +662,16 @@ impl App { } } +/// Next menu selection when navigating down, or None at the end / when the list is empty. +/// Returning Option avoids underflowing `count` for an empty category (e.g. Custom with no files). +fn next_menu_index(current: usize, count: usize) -> Option { + if current + 1 < count { + Some(current + 1) + } else { + None + } +} + /// Check if adaptive mode should be shown in the menu fn should_show_adaptive_mode(stats: &Stats) -> bool { if let Some(analytics) = &stats.adaptive_analytics { @@ -667,3 +680,25 @@ fn should_show_adaptive_mode(stats: &Stats) -> bool { false } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_menu_index_empty_list_stays_put() { + // Regression: pressing Down in an empty Custom lesson menu must not underflow. + assert_eq!(next_menu_index(0, 0), None); + } + + #[test] + fn next_menu_index_advances_within_bounds() { + assert_eq!(next_menu_index(0, 3), Some(1)); + assert_eq!(next_menu_index(1, 3), Some(2)); + } + + #[test] + fn next_menu_index_stops_at_last_item() { + assert_eq!(next_menu_index(2, 3), None); + } +} diff --git a/src/content/adaptive_generator.rs b/src/content/adaptive_generator.rs index e3e7d90..f078f7e 100644 --- a/src/content/adaptive_generator.rs +++ b/src/content/adaptive_generator.rs @@ -53,7 +53,7 @@ impl<'a> AdaptiveLessonGenerator<'a> { let learning_threshold = beginner_threshold + MasteryLevel::Learning.practice_weight(); // 0.9 let proficient_threshold = learning_threshold + MasteryLevel::Proficient.practice_weight(); // 1.0 - while result.len() < length { + while result.chars().count() < length { if !result.is_empty() { result.push(' '); } @@ -200,7 +200,7 @@ mod tests { let content = generator.generate(100); assert!(!content.is_empty()); - assert!(content.len() <= 100); + assert!(content.chars().count() <= 100); } #[test] @@ -222,7 +222,7 @@ mod tests { let content = generator.generate(50); assert!(!content.is_empty()); - assert!(content.len() <= 50); + assert!(content.chars().count() <= 50); } #[test] diff --git a/src/content/bigram_generator.rs b/src/content/bigram_generator.rs index 21ad522..cf37173 100644 --- a/src/content/bigram_generator.rs +++ b/src/content/bigram_generator.rs @@ -1,6 +1,6 @@ /// Content generator for bigram training lessons use super::bigram::{code_bigrams, english_bigrams, french_bigrams, Bigram, BigramType, Language}; -use rand::Rng; +use super::ngram_generator::{generate_drill_mode, generate_mixed_mode, generate_word_mode}; pub struct BigramGenerator { bigrams: Vec, @@ -29,9 +29,9 @@ impl BigramGenerator { let selected_bigrams = self.select_bigrams_for_level(level); match level { - 1 => self.generate_drill_mode(&selected_bigrams, length), - 2 => self.generate_word_mode(&selected_bigrams, length), - 3 | 4 => self.generate_mixed_mode(&selected_bigrams, length), + 1 => generate_drill_mode(&selected_bigrams, length), + 2 => generate_word_mode(&selected_bigrams, length), + 3 | 4 => generate_mixed_mode(&selected_bigrams, length), _ => String::new(), } } @@ -48,85 +48,6 @@ impl BigramGenerator { self.bigrams.iter().take(count).collect() } - - /// Level 1: Pure bigram repetition - /// Example: "qu qu qu ou ou ou en en en" - fn generate_drill_mode(&self, bigrams: &[&Bigram], length: usize) -> String { - let mut result = String::new(); - let mut rng = rand::thread_rng(); - let mut idx = 0; - - while result.len() < length { - if !result.is_empty() { - let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' }; - result.push(separator); - } - - let bigram = bigrams[idx % bigrams.len()]; - // Repeat the bigram 3 times - result.push_str(&format!( - "{} {} {}", - bigram.pattern, bigram.pattern, bigram.pattern - )); - - idx += 1; - } - - result.chars().take(length).collect() - } - - /// Level 2: Bigrams in word context - /// Example: "que qui quoi pour vous nous" - fn generate_word_mode(&self, bigrams: &[&Bigram], length: usize) -> String { - let mut result = String::new(); - let mut rng = rand::thread_rng(); - let mut bigram_idx = 0; - - while result.len() < length { - if !result.is_empty() { - let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' }; - result.push(separator); - } - - let bigram = bigrams[bigram_idx % bigrams.len()]; - - // Cycle through examples for this bigram - let example_idx = (bigram_idx / bigrams.len()) % bigram.examples.len(); - let word = &bigram.examples[example_idx]; - - result.push_str(word); - bigram_idx += 1; - } - - result.chars().take(length).collect() - } - - /// Level 3: Realistic sentences with target bigrams - /// Combines examples into natural-looking phrases - fn generate_mixed_mode(&self, bigrams: &[&Bigram], length: usize) -> String { - let mut result = String::new(); - let mut rng = rand::thread_rng(); - let mut word_count = 0; - - while result.len() < length { - if word_count > 0 { - let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' }; - result.push(separator); - } - - // Pick a bigram - let bigram = bigrams[word_count % bigrams.len()]; - - // Pick an example - let example_idx = (word_count / bigrams.len()) % bigram.examples.len(); - let word = &bigram.examples[example_idx]; - - result.push_str(word); - word_count += 1; - } - - result.chars().take(length).collect() - } } #[cfg(test)] @@ -139,7 +60,7 @@ mod tests { let content = gen.generate(1, 30); assert!(!content.is_empty()); - assert!(content.len() <= 30); + assert!(content.chars().count() <= 30); // Should contain repeated bigrams assert!(content.contains("es es es") || content.contains("le le le")); @@ -212,6 +133,23 @@ mod tests { assert!(level3.split_whitespace().any(|w| w.len() > 2)); } + #[test] + fn test_accented_content_fills_requested_char_length() { + // Regression: French bigrams include multibyte chars (é, è, à...). A byte-based + // generation loop stops short of the requested CHAR length. Generated content must + // get close to the target measured in chars (within one trailing word). + let gen = BigramGenerator::new(BigramType::Natural, Some(Language::French)); + let length = 80; + let content = gen.generate(2, length); + + let char_count = content.chars().count(); + assert!(char_count <= length); + assert!( + char_count >= length - 15, + "expected near {length} chars, got {char_count}: {content:?}" + ); + } + #[test] fn test_random_newlines_in_generation() { let gen = BigramGenerator::new(BigramType::Natural, Some(Language::French)); diff --git a/src/content/code_generator.rs b/src/content/code_generator.rs index 4088496..7487191 100644 --- a/src/content/code_generator.rs +++ b/src/content/code_generator.rs @@ -34,7 +34,7 @@ impl CodeSymbolGenerator { let mut result = String::new(); let mut idx = 0; - while result.len() < length { + while result.chars().count() < length { if !result.is_empty() { result.push('\n'); } @@ -58,7 +58,7 @@ mod tests { let content = gen.generate(1, 50); assert!(!content.is_empty()); - assert!(content.len() <= 50); + assert!(content.chars().count() <= 50); } #[test] @@ -67,7 +67,7 @@ mod tests { let content = gen.generate(1, 50); assert!(!content.is_empty()); - assert!(content.len() <= 50); + assert!(content.chars().count() <= 50); } #[test] @@ -76,7 +76,7 @@ mod tests { let content = gen.generate(1, 50); assert!(!content.is_empty()); - assert!(content.len() <= 50); + assert!(content.chars().count() <= 50); } #[test] diff --git a/src/content/custom.rs b/src/content/custom.rs index 40656d5..8a1811d 100644 --- a/src/content/custom.rs +++ b/src/content/custom.rs @@ -146,7 +146,14 @@ fn scan_directory(dir: &Path) -> Vec { // Read directory entries let entries = match fs::read_dir(dir) { Ok(entries) => entries, - Err(_) => return Vec::new(), + Err(e) => { + eprintln!( + "Warning: cannot read custom lesson directory {}: {}", + dir.display(), + e + ); + return Vec::new(); + } }; for entry in entries.flatten() { @@ -345,6 +352,46 @@ mod tests { fs::remove_file(temp_file).unwrap(); } + #[test] + fn test_parse_missing_closing_delimiter_falls_back_to_body() { + // No closing `---`: by design the whole file is treated as body content. + let content = "---\ntitle: Test\nstill front matter, no close"; + let temp_file = std::env::temp_dir().join("test_no_close.md"); + fs::write(&temp_file, content).unwrap(); + + let result = parse_markdown_file(&temp_file).unwrap(); + assert_eq!(result.metadata.title, None); // not parsed as front matter + assert_eq!(result.content, content.trim()); + + fs::remove_file(temp_file).unwrap(); + } + + #[test] + fn test_parse_crlf_front_matter() { + // str::lines() strips the trailing \r, so CRLF front matter parses correctly. + let content = "---\r\ntitle: Test\r\n---\r\n\r\nContent here"; + let temp_file = std::env::temp_dir().join("test_crlf.md"); + fs::write(&temp_file, content).unwrap(); + + let result = parse_markdown_file(&temp_file).unwrap(); + assert_eq!(result.metadata.title, Some("Test".to_string())); + assert_eq!(result.content, "Content here"); + + fs::remove_file(temp_file).unwrap(); + } + + #[test] + fn test_parse_file_too_large_rejected() { + let big = "a".repeat(MAX_FILE_SIZE + 1); + let temp_file = std::env::temp_dir().join("test_too_large.md"); + fs::write(&temp_file, &big).unwrap(); + + let result = parse_markdown_file(&temp_file); + assert!(matches!(result, Err(ParseError::FileTooLarge(_)))); + + fs::remove_file(temp_file).unwrap(); + } + #[test] fn test_deduplicate_titles_none() { let mut lessons = vec![ diff --git a/src/content/finger_generator.rs b/src/content/finger_generator.rs index ac5082f..95c840d 100644 --- a/src/content/finger_generator.rs +++ b/src/content/finger_generator.rs @@ -66,6 +66,32 @@ pub fn get_finger_pair_keys( keys } +/// Assemble drill content by cycling through `patterns` up to `length` chars, +/// joining with random space/newline separators. Char-based so multibyte (accented) +/// patterns fill the requested character budget, not a byte budget. +fn assemble_drill(patterns: &[String], length: usize, rng: &mut impl Rng) -> String { + let mut result = String::new(); + let mut idx = 0; + + while result.chars().count() < length { + if !result.is_empty() { + let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' }; + result.push(separator); + if result.chars().count() >= length { + break; + } + } + let pattern = &patterns[idx % patterns.len()]; + if result.chars().count() + pattern.chars().count() > length { + break; + } + result.push_str(pattern); + idx += 1; + } + + result +} + /// Generate finger-pair drill content using 3-phase pattern /// If with_shift is true, uses weighted distribution (50% lower, 40% upper, 10% symbols) pub fn generate_finger_drills(keys: &[char], length: usize, with_shift: bool) -> String { @@ -82,7 +108,6 @@ pub fn generate_finger_drills(keys: &[char], length: usize, with_shift: bool) -> /// Generate drills with only base characters (3-phase pattern) fn generate_base_drills(keys: &[char], length: usize) -> String { - let mut result = String::new(); let mut rng = rand::thread_rng(); let mut patterns = Vec::new(); @@ -115,27 +140,7 @@ fn generate_base_drills(keys: &[char], length: usize) -> String { } } - // Generate content by cycling through patterns - let mut idx = 0; - while result.len() < length { - if !result.is_empty() { - let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' }; - result.push(separator); - // Check if adding separator would exceed length - if result.len() >= length { - break; - } - } - let pattern = &patterns[idx % patterns.len()]; - // Check if adding pattern would exceed length - if result.len() + pattern.len() > length { - break; - } - result.push_str(pattern); - idx += 1; - } - - result + assemble_drill(&patterns, length, &mut rng) } /// Generate drills with shift variants (50% lower, 40% upper, 10% symbols) @@ -181,7 +186,6 @@ fn generate_shift_drills(keys: &[char], length: usize) -> String { } // Generate drill with 3-phase pattern using weighted pool - let mut result = String::new(); let mut patterns = Vec::new(); // Phase 1: Repetitions @@ -205,27 +209,7 @@ fn generate_shift_drills(keys: &[char], length: usize) -> String { patterns.push(format!("{}{}{}", c1, c2, c3)); } - // Generate content - let mut idx = 0; - while result.len() < length { - if !result.is_empty() { - let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' }; - result.push(separator); - // Check if adding separator would exceed length - if result.len() >= length { - break; - } - } - let pattern = &patterns[idx % patterns.len()]; - // Check if adding pattern would exceed length - if result.len() + pattern.len() > length { - break; - } - result.push_str(pattern); - idx += 1; - } - - result + assemble_drill(&patterns, length, &mut rng) } #[cfg(test)] @@ -335,7 +319,7 @@ mod tests { let content = generate_finger_drills(&keys, 50, false); assert!(!content.is_empty()); - assert!(content.len() <= 50); + assert!(content.chars().count() <= 50); assert!(content.contains('d') && content.contains('k')); } @@ -345,7 +329,7 @@ mod tests { let content = generate_finger_drills(&keys, 100, true); assert!(!content.is_empty()); - assert!(content.len() <= 100); + assert!(content.chars().count() <= 100); // Should contain both lowercase and uppercase assert!(content.contains('d') || content.contains('D')); assert!(content.contains('k') || content.contains('K')); @@ -371,7 +355,7 @@ mod tests { level, with_shift ); - assert!(content.len() <= 100); + assert!(content.chars().count() <= 100); } } } diff --git a/src/content/generator.rs b/src/content/generator.rs index 4ac7ae3..ca7bfdf 100644 --- a/src/content/generator.rs +++ b/src/content/generator.rs @@ -183,11 +183,11 @@ mod tests { // Test Level 1 lesson let content = lessons[0].generate(50); assert!(!content.is_empty()); - assert!(content.len() <= 50); + assert!(content.chars().count() <= 50); // Test Level 4 lesson let content_l4 = lessons[6].generate(100); assert!(!content_l4.is_empty()); - assert!(content_l4.len() <= 100); + assert!(content_l4.chars().count() <= 100); } } diff --git a/src/content/mod.rs b/src/content/mod.rs index b47141e..6b05d35 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -10,6 +10,7 @@ pub mod custom; pub mod finger_generator; pub mod generator; pub mod lesson; +pub mod ngram_generator; pub mod trigram; pub mod trigram_generator; diff --git a/src/content/ngram_generator.rs b/src/content/ngram_generator.rs new file mode 100644 index 0000000..8d8d1b5 --- /dev/null +++ b/src/content/ngram_generator.rs @@ -0,0 +1,98 @@ +//! Shared drill-generation logic for n-gram lessons (bigrams, trigrams). +//! +//! Bigram and Trigram generators previously duplicated the drill/word/mixed-mode +//! loops verbatim; the only difference was the item type. These generic functions +//! operate over any [`DrillItem`] so the control flow lives in one place. + +use super::bigram::Bigram; +use super::trigram::Trigram; +use rand::Rng; + +/// A practice n-gram exposing its raw pattern and example words. +pub trait DrillItem { + fn pattern(&self) -> &str; + fn examples(&self) -> &[String]; +} + +impl DrillItem for Bigram { + fn pattern(&self) -> &str { + &self.pattern + } + fn examples(&self) -> &[String] { + &self.examples + } +} + +impl DrillItem for Trigram { + fn pattern(&self) -> &str { + &self.pattern + } + fn examples(&self) -> &[String] { + &self.examples + } +} + +/// Random space/newline separator (25% newline) appended between practice chunks. +fn push_separator(result: &mut String, rng: &mut impl Rng) { + let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' }; + result.push(separator); +} + +/// Level 1: pure pattern repetition, e.g. "qu qu qu ou ou ou". +pub fn generate_drill_mode(items: &[&T], length: usize) -> String { + let mut result = String::new(); + let mut rng = rand::thread_rng(); + let mut idx = 0; + + while result.chars().count() < length { + if !result.is_empty() { + push_separator(&mut result, &mut rng); + } + + let pattern = items[idx % items.len()].pattern(); + result.push_str(&format!("{pattern} {pattern} {pattern}")); + idx += 1; + } + + result.chars().take(length).collect() +} + +/// Level 2: patterns in word context, cycling through each item's examples. +pub fn generate_word_mode(items: &[&T], length: usize) -> String { + let mut result = String::new(); + let mut rng = rand::thread_rng(); + let mut idx = 0; + + while result.chars().count() < length { + if !result.is_empty() { + push_separator(&mut result, &mut rng); + } + + let item = items[idx % items.len()]; + let example_idx = (idx / items.len()) % item.examples().len(); + result.push_str(&item.examples()[example_idx]); + idx += 1; + } + + result.chars().take(length).collect() +} + +/// Level 3-4: realistic sentences mixing the target patterns' example words. +pub fn generate_mixed_mode(items: &[&T], length: usize) -> String { + let mut result = String::new(); + let mut rng = rand::thread_rng(); + let mut word_count = 0; + + while result.chars().count() < length { + if word_count > 0 { + push_separator(&mut result, &mut rng); + } + + let item = items[word_count % items.len()]; + let example_idx = (word_count / items.len()) % item.examples().len(); + result.push_str(&item.examples()[example_idx]); + word_count += 1; + } + + result.chars().take(length).collect() +} diff --git a/src/content/trigram_generator.rs b/src/content/trigram_generator.rs index 1c74ca3..f00617a 100644 --- a/src/content/trigram_generator.rs +++ b/src/content/trigram_generator.rs @@ -1,7 +1,7 @@ /// Content generator for trigram training lessons use super::bigram::Language; +use super::ngram_generator::{generate_drill_mode, generate_mixed_mode, generate_word_mode}; use super::trigram::{english_trigrams, french_trigrams, Trigram}; -use rand::Rng; pub struct TrigramGenerator { trigrams: Vec, @@ -26,9 +26,9 @@ impl TrigramGenerator { let selected_trigrams = self.select_trigrams_for_level(level); match level { - 1 => self.generate_drill_mode(&selected_trigrams, length), - 2 => self.generate_word_mode(&selected_trigrams, length), - 3 | 4 => self.generate_mixed_mode(&selected_trigrams, length), + 1 => generate_drill_mode(&selected_trigrams, length), + 2 => generate_word_mode(&selected_trigrams, length), + 3 | 4 => generate_mixed_mode(&selected_trigrams, length), _ => String::new(), } } @@ -45,85 +45,6 @@ impl TrigramGenerator { self.trigrams.iter().take(count).collect() } - - /// Level 1: Pure trigram repetition - /// Example: "the the the and and and" - fn generate_drill_mode(&self, trigrams: &[&Trigram], length: usize) -> String { - let mut result = String::new(); - let mut rng = rand::thread_rng(); - let mut idx = 0; - - while result.len() < length { - if !result.is_empty() { - let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' }; - result.push(separator); - } - - let trigram = trigrams[idx % trigrams.len()]; - // Repeat the trigram 3 times - result.push_str(&format!( - "{} {} {}", - trigram.pattern, trigram.pattern, trigram.pattern - )); - - idx += 1; - } - - result.chars().take(length).collect() - } - - /// Level 2: Trigrams in word context - /// Example: "the them then and hand stand" - fn generate_word_mode(&self, trigrams: &[&Trigram], length: usize) -> String { - let mut result = String::new(); - let mut rng = rand::thread_rng(); - let mut trigram_idx = 0; - - while result.len() < length { - if !result.is_empty() { - let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' }; - result.push(separator); - } - - let trigram = trigrams[trigram_idx % trigrams.len()]; - - // Cycle through examples for this trigram - let example_idx = (trigram_idx / trigrams.len()) % trigram.examples.len(); - let word = &trigram.examples[example_idx]; - - result.push_str(word); - trigram_idx += 1; - } - - result.chars().take(length).collect() - } - - /// Level 3-4: Realistic sentences with target trigrams - /// Combines examples into natural-looking phrases - fn generate_mixed_mode(&self, trigrams: &[&Trigram], length: usize) -> String { - let mut result = String::new(); - let mut rng = rand::thread_rng(); - let mut word_count = 0; - - while result.len() < length { - if word_count > 0 { - let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' }; - result.push(separator); - } - - // Pick a trigram - let trigram = trigrams[word_count % trigrams.len()]; - - // Pick an example - let example_idx = (word_count / trigrams.len()) % trigram.examples.len(); - let word = &trigram.examples[example_idx]; - - result.push_str(word); - word_count += 1; - } - - result.chars().take(length).collect() - } } #[cfg(test)] @@ -136,7 +57,7 @@ mod tests { let content = gen.generate(1, 30); assert!(!content.is_empty()); - assert!(content.len() <= 30); + assert!(content.chars().count() <= 30); // Should contain repeated trigrams assert!(content.contains("les les les") || content.contains("des des des")); @@ -148,7 +69,7 @@ mod tests { let content = gen.generate(2, 50); assert!(!content.is_empty()); - assert!(content.len() <= 50); + assert!(content.chars().count() <= 50); // Should contain real words, not drill patterns assert!( @@ -165,7 +86,7 @@ mod tests { let content = gen.generate(3, 60); assert!(!content.is_empty()); - assert!(content.len() <= 60); + assert!(content.chars().count() <= 60); // Should contain multiple words let word_count = content.split_whitespace().count(); diff --git a/src/data/stats.rs b/src/data/stats.rs index a3a9cf9..a92a52f 100644 --- a/src/data/stats.rs +++ b/src/data/stats.rs @@ -235,6 +235,62 @@ mod tests { assert_eq!(stats.average_accuracy(), 95.0); } + #[test] + fn test_update_analytics_accumulates_across_sessions() { + use crate::engine::analytics::SessionAnalyzer; + use crate::engine::types::CharInput; + use std::time::Instant; + + // One correct 'a' then a mistyped 'b' (typed 'x'). + let build_session = || TypingSession { + content: "ab".to_string(), + chars: "ab".chars().collect(), + current_index: 2, + duration_limit: Duration::from_secs(300), + content_buffer_size: 2, + inputs: vec![ + CharInput { + expected: 'a', + typed: 'a', + timestamp: Duration::from_millis(100), + is_correct: true, + }, + CharInput { + expected: 'b', + typed: 'x', + timestamp: Duration::from_millis(250), + is_correct: false, + }, + ], + start_time: Some(Instant::now()), + end_time: Some(Instant::now()), + }; + + let analyzer = SessionAnalyzer::new(); + let mut stats = Stats::new(); + + let s1 = build_session(); + stats.update_analytics(&s1, analyzer.analyze_session(&s1)); + let s2 = build_session(); + stats.update_analytics(&s2, analyzer.analyze_session(&s2)); + + let analytics = stats.adaptive_analytics.as_ref().unwrap(); + + // Per-key totals must SUM across the two sessions, not overwrite. + let a = &analytics.key_stats[&'a']; + assert_eq!(a.total_attempts, 2); + assert_eq!(a.correct_attempts, 2); + + let b = &analytics.key_stats[&'b']; + assert_eq!(b.total_attempts, 2); + assert_eq!(b.error_count, 2); + // mistype_map increments per occurrence. + assert_eq!(b.mistype_map[&'x'], 2); + + assert_eq!(analytics.total_sessions, 2); + assert_eq!(analytics.total_keystrokes, 4); + } + #[test] fn test_session_record_serialization() { let record = SessionRecord::new( diff --git a/src/data/storage.rs b/src/data/storage.rs index 307d3df..5d0bb12 100644 --- a/src/data/storage.rs +++ b/src/data/storage.rs @@ -1,7 +1,51 @@ use super::stats::Stats; use std::fs; use std::io; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +/// Write `content` atomically: write to a sibling temp file, then rename over the +/// target. Rename is atomic on the same filesystem, so an interrupted write cannot +/// truncate or corrupt the existing file. +fn atomic_write(path: &Path, content: &str) -> io::Result<()> { + let tmp_path = path.with_extension("tmp"); + fs::write(&tmp_path, content).map_err(|e| { + io::Error::new( + e.kind(), + format!("Failed to write temp file {}: {}", tmp_path.display(), e), + ) + })?; + fs::rename(&tmp_path, path).map_err(|e| { + io::Error::new( + e.kind(), + format!( + "Failed to replace {} with {}: {}", + path.display(), + tmp_path.display(), + e + ), + ) + }) +} + +/// Move a corrupt JSON file aside to `.bak` so the app can start fresh instead of +/// failing to launch. Best-effort: a failed backup is reported but not fatal. +fn back_up_corrupt_file(path: &Path) { + let backup = path.with_extension("bak"); + if let Err(e) = fs::rename(path, &backup) { + eprintln!( + "Warning: could not back up corrupt file {} to {}: {}", + path.display(), + backup.display(), + e + ); + } else { + eprintln!( + "Warning: {} was corrupt and has been moved to {}; starting fresh.", + path.display(), + backup.display() + ); + } +} /// Gestionnaire de stockage des stats pub struct Storage { @@ -30,71 +74,96 @@ impl Storage { /// Récupérer le dossier de configuration fn get_config_dir() -> io::Result { let home = std::env::var("HOME").map_err(|_| { - io::Error::new(io::ErrorKind::NotFound, "HOME environment variable not set") + io::Error::new( + io::ErrorKind::NotFound, + "Cannot locate config directory: HOME is not set. Set HOME to your home directory.", + ) })?; Ok(PathBuf::from(home).join(".config").join("typer-cli")) } - /// Charger les stats depuis le fichier + /// Charger les stats depuis le fichier. + /// A corrupt file is moved aside and defaults are returned, so the app always launches. pub fn load(&self) -> io::Result { if !self.file_path.exists() { return Ok(Stats::new()); } - let content = fs::read_to_string(&self.file_path)?; - let stats: Stats = serde_json::from_str(&content).map_err(|e| { + let content = fs::read_to_string(&self.file_path).map_err(|e| { io::Error::new( - io::ErrorKind::InvalidData, - format!("Failed to parse stats: {}", e), + e.kind(), + format!("Failed to read stats {}: {}", self.file_path.display(), e), ) })?; - Ok(stats) + match serde_json::from_str(&content) { + Ok(stats) => Ok(stats), + Err(_) => { + back_up_corrupt_file(&self.file_path); + Ok(Stats::new()) + } + } } - /// Sauvegarder les stats dans le fichier + /// Sauvegarder les stats dans le fichier (atomic write). pub fn save(&self, stats: &Stats) -> io::Result<()> { let content = serde_json::to_string_pretty(stats).map_err(|e| { io::Error::new( io::ErrorKind::InvalidData, - format!("Failed to serialize stats: {}", e), + format!( + "Failed to serialize stats for {}: {}", + self.file_path.display(), + e + ), ) })?; - fs::write(&self.file_path, content)?; - Ok(()) + atomic_write(&self.file_path, &content) } + /// A corrupt config file is moved aside and defaults are returned, so the app always launches. pub fn load_config(&self) -> io::Result { let config_path = self.config_path(); if !config_path.exists() { return Ok(crate::data::Config::default()); } - let content = fs::read_to_string(&config_path)?; - serde_json::from_str(&content).map_err(|e| { + let content = fs::read_to_string(&config_path).map_err(|e| { io::Error::new( - io::ErrorKind::InvalidData, - format!("Failed to parse config: {}", e), + e.kind(), + format!("Failed to read config {}: {}", config_path.display(), e), ) - }) + })?; + match serde_json::from_str(&content) { + Ok(config) => Ok(config), + Err(_) => { + back_up_corrupt_file(&config_path); + Ok(crate::data::Config::default()) + } + } } pub fn save_config(&self, config: &crate::data::Config) -> io::Result<()> { + let config_path = self.config_path(); let content = serde_json::to_string_pretty(config).map_err(|e| { io::Error::new( io::ErrorKind::InvalidData, - format!("Failed to serialize config: {}", e), + format!( + "Failed to serialize config for {}: {}", + config_path.display(), + e + ), ) })?; - fs::write(self.config_path(), content)?; - Ok(()) + atomic_write(&config_path, &content) } - fn config_path(&self) -> std::path::PathBuf { + fn config_path(&self) -> PathBuf { + // Fall back to the current directory rather than panicking if the stats path + // somehow has no parent. self.file_path .parent() - .expect("stats path always has a parent dir") + .unwrap_or_else(|| Path::new(".")) .join("config.json") } @@ -174,11 +243,56 @@ mod tests { } #[test] - fn test_load_config_invalid_json_returns_error() { + fn test_load_config_corrupt_json_recovers_to_default() { + // A corrupt config must not block launch: it is backed up and defaults returned. let (storage, temp_dir) = create_test_storage(); let config_path = temp_dir.path().join("config.json"); fs::write(&config_path, b"not valid json").unwrap(); - let result = storage.load_config(); - assert!(result.is_err()); + + let config = storage.load_config().unwrap(); + assert_eq!(config.layout_variant, crate::keyboard::LayoutVariant::Mac); + // Corrupt file moved aside. + assert!(!config_path.exists()); + assert!(temp_dir.path().join("config.bak").exists()); + } + + #[test] + fn test_load_corrupt_stats_recovers_to_empty() { + // A corrupt stats file must not block launch. + let (storage, temp_dir) = create_test_storage(); + fs::write(temp_dir.path().join("test_stats.json"), b"{ broken json").unwrap(); + + let stats = storage.load().unwrap(); + assert_eq!(stats.session_count(), 0); + assert!(!temp_dir.path().join("test_stats.json").exists()); + assert!(temp_dir.path().join("test_stats.bak").exists()); + } + + #[test] + fn test_save_leaves_no_temp_file() { + // Atomic write must rename the temp file away, not leave it behind. + let (storage, temp_dir) = create_test_storage(); + storage.save(&Stats::new()).unwrap(); + + assert!(temp_dir.path().join("test_stats.json").exists()); + assert!(!temp_dir.path().join("test_stats.tmp").exists()); + } + + #[test] + fn test_save_preserves_previous_file_on_serialize_path() { + // Round-trip through atomic write keeps data intact. + let (storage, _temp_dir) = create_test_storage(); + let mut stats = Stats::new(); + stats.add_session(SessionRecord::new( + "HomeRow-1".to_string(), + 45.0, + 95.0, + Duration::from_secs(60), + Duration::from_secs(300), + )); + storage.save(&stats).unwrap(); + storage.save(&stats).unwrap(); // overwrite existing file atomically + + assert_eq!(storage.load().unwrap().session_count(), 1); } } diff --git a/src/engine/adaptive.rs b/src/engine/adaptive.rs index 45fa7f8..e59252f 100644 --- a/src/engine/adaptive.rs +++ b/src/engine/adaptive.rs @@ -167,11 +167,26 @@ mod tests { // Get keys slower than 75th percentile (top 25% slowest) let slow_keys = WeaknessDetector::identify_slow_keys(&analytics, 0.75); - // The slowest keys (f, g) should be identified - // With 7 keys, 75th percentile index = 5 (0-based), value = 300 - // Keys > 300 should be returned: 'g' (350) - assert!(!slow_keys.is_empty()); - assert!(slow_keys.contains(&'g')); + // With 7 keys, threshold_idx = (7 * 0.75) = 5, value = 300. + // Strict `>` means only keys slower than 300 are returned: exactly {'g'} (350). + // 'f' (300) sits at the boundary and is excluded. + assert_eq!(slow_keys, vec!['g']); + } + + #[test] + fn test_identify_slow_keys_excludes_boundary_ties() { + let mut analytics = AdaptiveAnalytics::default(); + // Two keys share the threshold value; neither is strictly slower than it. + for (key, time_ms) in [('a', 100), ('b', 200), ('c', 200), ('d', 200)] { + let mut stats = KeyStats::new(key); + stats.correct_attempts = 10; + stats.total_time_ms = time_ms; + analytics.key_stats.insert(key, stats); + } + + // threshold_idx = (4 * 0.5) = 2, value = 200; nothing is > 200. + let slow_keys = WeaknessDetector::identify_slow_keys(&analytics, 0.5); + assert!(slow_keys.is_empty()); } #[test] diff --git a/src/engine/analytics.rs b/src/engine/analytics.rs index 9c1642f..eaa7c63 100644 --- a/src/engine/analytics.rs +++ b/src/engine/analytics.rs @@ -205,6 +205,17 @@ impl SessionAnalyzer { let expected = input.expected; let typed = input.typed; + // timestamp is cumulative elapsed time since session start, so per-keystroke + // latency is the delta from the previous input (or from start for the first key). + let prev_timestamp = i + .checked_sub(1) + .map(|p| session.inputs[p].timestamp) + .unwrap_or_default(); + let keystroke_time = input + .timestamp + .checked_sub(prev_timestamp) + .unwrap_or_default(); + // Update per-key statistics let perf = key_performance.entry(expected).or_default(); @@ -212,7 +223,7 @@ impl SessionAnalyzer { if input.is_correct { perf.correct_attempts += 1; - perf.timings.push(input.timestamp); + perf.timings.push(keystroke_time); } else { perf.errors.push(typed); } @@ -229,14 +240,8 @@ impl SessionAnalyzer { bigram_perf.total_attempts += 1; bigram_perf.correct_attempts += 1; - // Bigram timing is the difference between the two keystrokes - if let Some(prev_time) = - prev_input.timestamp.checked_sub(Duration::from_secs(0)) - { - if let Some(time_diff) = input.timestamp.checked_sub(prev_time) { - bigram_perf.timings.push(time_diff); - } - } + // Bigram timing is the interval between the two keystrokes. + bigram_perf.timings.push(keystroke_time); } } } @@ -333,6 +338,7 @@ mod tests { let session = TypingSession { content: "test".to_string(), + chars: "test".chars().collect(), current_index: 4, duration_limit: Duration::from_secs(300), content_buffer_size: 4, @@ -376,4 +382,93 @@ mod tests { assert_eq!(analysis.key_performance[&'e'].total_attempts, 1); assert_eq!(analysis.key_performance[&'s'].total_attempts, 1); } + + #[test] + fn test_session_analyzer_accented_bigram() { + use std::time::Instant; + + // "ré" -> per-key 'r' and 'é' plus the bigram "ré". + let session = TypingSession { + content: "ré".to_string(), + chars: "ré".chars().collect(), + current_index: 2, + duration_limit: Duration::from_secs(300), + content_buffer_size: 2, + inputs: vec![ + CharInput { + expected: 'r', + typed: 'r', + timestamp: Duration::from_millis(100), + is_correct: true, + }, + CharInput { + expected: 'é', + typed: 'é', + timestamp: Duration::from_millis(250), + is_correct: true, + }, + ], + start_time: Some(Instant::now()), + end_time: Some(Instant::now()), + }; + + let analysis = SessionAnalyzer::new().analyze_session(&session); + + assert_eq!(analysis.key_performance[&'é'].total_attempts, 1); + assert_eq!(analysis.key_performance[&'é'].correct_attempts, 1); + assert!(analysis.bigram_performance.contains_key("ré")); + } + + #[test] + fn test_per_key_timings_are_intervals_not_absolute_timestamps() { + use std::time::Instant; + + // Cumulative timestamps 100/250/400ms -> per-key intervals 100/150/150ms. + let session = TypingSession { + content: "abc".to_string(), + chars: "abc".chars().collect(), + current_index: 3, + duration_limit: Duration::from_secs(300), + content_buffer_size: 3, + inputs: vec![ + CharInput { + expected: 'a', + typed: 'a', + timestamp: Duration::from_millis(100), + is_correct: true, + }, + CharInput { + expected: 'b', + typed: 'b', + timestamp: Duration::from_millis(250), + is_correct: true, + }, + CharInput { + expected: 'c', + typed: 'c', + timestamp: Duration::from_millis(400), + is_correct: true, + }, + ], + start_time: Some(Instant::now()), + end_time: Some(Instant::now()), + }; + + let analysis = SessionAnalyzer::new().analyze_session(&session); + + // First key: delta from session start. Later keys: delta from the previous key, + // NOT the absolute elapsed timestamp. + assert_eq!( + analysis.key_performance[&'a'].timings, + vec![Duration::from_millis(100)] + ); + assert_eq!( + analysis.key_performance[&'b'].timings, + vec![Duration::from_millis(150)] + ); + assert_eq!( + analysis.key_performance[&'c'].timings, + vec![Duration::from_millis(150)] + ); + } } diff --git a/src/engine/scoring.rs b/src/engine/scoring.rs index c567bd4..210a3aa 100644 --- a/src/engine/scoring.rs +++ b/src/engine/scoring.rs @@ -5,11 +5,7 @@ use std::time::Duration; pub fn calculate_results(session: &TypingSession) -> SessionResult { let char_count = session.inputs.len(); let error_count = session.inputs.iter().filter(|i| !i.is_correct).count(); - let accuracy = if char_count > 0 { - ((char_count - error_count) as f64 / char_count as f64) * 100.0 - } else { - 0.0 - }; + let accuracy = calculate_accuracy(char_count - error_count, char_count); let duration = session.duration(); let wpm = calculate_wpm(char_count, duration); @@ -31,11 +27,7 @@ pub fn calculate_wpm(char_count: usize, duration: Duration) -> f64 { words / minutes } -/// Calculer l'accuracy en pourcentage -/// Public API: Canonical accuracy calculation utility tested in line 68-83 -/// Note: Main code uses inline calculation in calculate_results() but this function -/// serves as the reference implementation and reusable utility -#[allow(dead_code)] +/// Calculer l'accuracy en pourcentage (correct / total * 100), 0 when total is 0. pub fn calculate_accuracy(correct: usize, total: usize) -> f64 { if total == 0 { return 0.0; diff --git a/src/engine/types.rs b/src/engine/types.rs index 535ef1c..0bd796b 100644 --- a/src/engine/types.rs +++ b/src/engine/types.rs @@ -69,6 +69,10 @@ impl CharInput { #[derive(Debug)] pub struct TypingSession { pub content: String, + /// Parallel char view of `content` for O(1) indexing on the per-keystroke and + /// per-frame hot paths (content can grow long via append_content). Kept in sync + /// with `content` by new() and append_content(). + pub chars: Vec, pub current_index: usize, pub inputs: Vec, pub start_time: Option, @@ -79,9 +83,11 @@ pub struct TypingSession { impl TypingSession { pub fn new(content: String, duration: Duration) -> Self { - let buffer_size = content.chars().count(); + let chars: Vec = content.chars().collect(); + let buffer_size = chars.len(); Self { content, + chars, current_index: 0, inputs: Vec::new(), start_time: None, @@ -91,6 +97,11 @@ impl TypingSession { } } + /// Expected character at a content index, or None past the end. O(1). + pub fn char_at(&self, index: usize) -> Option { + self.chars.get(index).copied() + } + /// Explicit session start method used in tests #[allow(dead_code)] pub fn start(&mut self) { @@ -109,7 +120,7 @@ impl TypingSession { return false; } - let expected = self.content.chars().nth(self.current_index).unwrap_or('\0'); + let expected = self.char_at(self.current_index).unwrap_or('\0'); let elapsed = self .start_time .map(|start| start.elapsed()) @@ -147,8 +158,9 @@ impl TypingSession { } } - // Fallback: content-based completion - self.current_index >= self.content.chars().count() + // Fallback: content-based completion. chars.len() is the cached char count, + // kept in sync by new()/append_content(), so this avoids an O(n) rescan per keystroke. + self.current_index >= self.chars.len() } pub fn duration(&self) -> Duration { @@ -182,7 +194,9 @@ impl TypingSession { pub fn append_content(&mut self, new_content: String) { self.content.push(' '); self.content.push_str(&new_content); - self.content_buffer_size = self.content.chars().count(); + self.chars.push(' '); + self.chars.extend(new_content.chars()); + self.content_buffer_size = self.chars.len(); } } @@ -328,6 +342,32 @@ mod tests { assert_eq!(session.inputs.len(), 0); } + #[test] + fn test_typing_session_accented_content() { + // AZERTY/French content is multibyte; indexing and completion must be char-based. + let mut session = TypingSession::new("déjà".to_string(), Duration::from_secs(60)); + assert_eq!(session.content_buffer_size, 4); // 4 chars, not 6 bytes + + assert!(session.add_input('d')); + assert!(session.add_input('é')); // multibyte, compared as a single char + assert!(session.add_input('j')); + assert!(!session.is_complete()); + assert!(session.add_input('à')); + assert!(session.is_complete()); + assert_eq!(session.current_index, 4); + } + + #[test] + fn test_typing_session_accented_mismatch() { + let mut session = TypingSession::new("café".to_string(), Duration::from_secs(60)); + session.add_input('c'); + session.add_input('a'); + session.add_input('f'); + assert!(!session.add_input('e')); // expected 'é', typed 'e' -> incorrect + assert!(!session.inputs[3].is_correct); + assert_eq!(session.inputs[3].expected, 'é'); + } + #[test] fn test_typing_session_backspace_after_completion() { let mut session = TypingSession::new("ab".to_string(), Duration::from_secs(60)); diff --git a/src/ui/analytics.rs b/src/ui/analytics.rs index 7d0a7c1..6ce4dfe 100644 --- a/src/ui/analytics.rs +++ b/src/ui/analytics.rs @@ -272,7 +272,8 @@ pub fn create_goal_progress_display( ) -> Vec { // Create progress bar let bar_length = 20; - let filled = (progress_percent / 100.0 * bar_length as f64).round() as usize; + // Clamp so a >100% progress value can't overfill the bar or underflow the empty portion. + let filled = ((progress_percent / 100.0 * bar_length as f64).round() as usize).min(bar_length); let progress_bar = format!("{}{}", "█".repeat(filled), "░".repeat(bar_length - filled)); let mut display = vec![ @@ -415,7 +416,7 @@ mod tests { assert_eq!(current_wpm, 45.0); assert_eq!(progress_percent, 90.0); // 45/50 * 100 assert!(status.contains("Keep going")); - assert!(details.len() > 0); + assert!(!details.is_empty()); assert!(details[0].contains("Today's Sessions: 1")); } @@ -431,4 +432,11 @@ mod tests { assert!(display[3].contains("██████████████░░")); // 90% progress = 18 filled + 2 empty assert!(display[5].contains("Keep going!")); } + + #[test] + fn test_create_goal_progress_display_over_100_percent_does_not_panic() { + // Regression: progress > 100% must not overfill/underflow the progress bar. + let display = create_goal_progress_display(120.0, 50.0, 150.0, "Done!", &[]); + assert!(display[3].contains("████████████████████")); // fully filled, 20 blocks + } } diff --git a/src/ui/render.rs b/src/ui/render.rs deleted file mode 100644 index 44401c5..0000000 --- a/src/ui/render.rs +++ /dev/null @@ -1,1916 +0,0 @@ -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph}, - Frame, -}; - -use crate::content::Lesson; -use crate::data::Stats; -use crate::engine::analytics::{AdaptiveAnalytics, MasteryLevel}; -use crate::engine::TypingSession; -use crate::keyboard::AzertyLayout; -use crate::ui::keyboard::{render_keyboard, render_keyboard_compact, KeyboardConfig}; -use std::collections::HashMap; - -/// Structure for visible text window -struct VisibleWindow { - lines: Vec, - /// Cursor line within visible window (always 0 for first line) - #[allow(dead_code)] - cursor_line: usize, - /// Cursor offset within the cursor line - #[allow(dead_code)] - cursor_offset: usize, - /// Which wrapped line number the window starts at in full content - #[allow(dead_code)] - window_start_line: usize, - /// Cumulative character count at start of each visible line (for index translation) - line_start_indices: Vec, -} - -/// Wrap text to fit terminal width, preserving newlines -fn wrap_text(content: &str, width: usize) -> Vec { - let mut lines = Vec::new(); - - // Split by newlines first to preserve them - for raw_line in content.split('\n') { - // Wrap each line if it's too long - let mut current_line = String::new(); - - for word in raw_line.split_whitespace() { - if current_line.is_empty() { - current_line = word.to_string(); - } else if current_line.len() + 1 + word.len() <= width { - current_line.push(' '); - current_line.push_str(word); - } else { - lines.push(current_line); - current_line = word.to_string(); - } - } - - // Always push the line (even if empty) to preserve blank lines - lines.push(current_line); - } - - lines -} - -/// Find which wrapped line contains a given character position -fn find_cursor_line(lines: &[String], char_pos: usize) -> (usize, usize) { - let mut char_count = 0; - - for (line_idx, line) in lines.iter().enumerate() { - let line_len = line.chars().count(); - - // Check if cursor is within this line's text - if char_pos < char_count + line_len { - return (line_idx, char_pos.saturating_sub(char_count)); - } - - // Check if cursor is on the newline at the end of this line - if char_pos == char_count + line_len && line_idx < lines.len() - 1 { - return (line_idx, line_len); - } - - // Move to next line: add line length + 1 for newline character - char_count += line_len + 1; - } - - // If not found, return last line - (lines.len().saturating_sub(1), 0) -} - -/// Extract 3-line visible window starting from cursor position -fn extract_visible_window(session: &TypingSession, width: usize) -> VisibleWindow { - let content = &session.content; - let cursor_pos = session.current_index; - - // Calculate effective width (subtract borders and padding) - let effective_width = width.saturating_sub(4); - - // Wrap text to terminal width - let lines = wrap_text(content, effective_width); - - // Find which line contains the cursor - let (cursor_line_idx, cursor_offset_in_line) = find_cursor_line(&lines, cursor_pos); - - // Extract 3 lines starting from cursor line - let visible_lines: Vec = lines - .iter() - .skip(cursor_line_idx) - .take(3) - .cloned() - .collect(); - - // Compute cumulative character indices for visible lines - let mut line_start_indices = Vec::new(); - for idx in cursor_line_idx..(cursor_line_idx + visible_lines.len()) { - // Calculate chars from start of content to this line - let chars_before_line: usize = lines - .iter() - .take(idx) - .map(|l| l.chars().count() + 1) // +1 for space between words - .sum(); - line_start_indices.push(chars_before_line); - } - - VisibleWindow { - lines: visible_lines, - cursor_line: 0, // Cursor is always on first visible line - cursor_offset: cursor_offset_in_line, - window_start_line: cursor_line_idx, - line_start_indices, - } -} - -/// Create styled expected text with character-level visual feedback -/// - Correctly typed characters: dark gray (dimmed) -/// - Mistyped characters: red (error indication) -/// - Next character to type: white + bold + underlined -/// - Remaining characters: white -fn create_styled_expected_text( - session: &TypingSession, - window: &VisibleWindow, -) -> Vec> { - let mut result_lines = Vec::new(); - - for (line_idx, line) in window.lines.iter().enumerate() { - let mut spans = Vec::new(); - let line_start_index = window.line_start_indices[line_idx]; - - // Render each character in the line - for (char_offset, ch) in line.chars().enumerate() { - let absolute_index = line_start_index + char_offset; - - let style = if absolute_index < session.current_index { - // Already typed - check if correct or incorrect - if absolute_index < session.inputs.len() { - let input = &session.inputs[absolute_index]; - if input.is_correct { - // Correct - dim (dark gray) - Style::default().fg(Color::DarkGray) - } else { - // Incorrect - red to show error - Style::default().fg(Color::Red) - } - } else { - // Fallback (shouldn't happen) - Style::default().fg(Color::DarkGray) - } - } else if absolute_index == session.current_index { - // Next character - highlight - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) - } else { - // Remaining - normal - Style::default().fg(Color::White) - }; - - // Display spaces as dots when they have errors, for visibility - let display_char = if ch == ' ' - && absolute_index < session.current_index - && absolute_index < session.inputs.len() - && !session.inputs[absolute_index].is_correct - { - '·' // Show dot for space errors - } else { - ch - }; - - spans.push(Span::styled(display_char.to_string(), style)); - } - - // Add newline icon at the end of each line (except the very last line in content) - let newline_index = line_start_index + line.chars().count(); - if newline_index < session.content.chars().count() { - // Determine if this position represents a newline in the original content - if session.content.chars().nth(newline_index) == Some('\n') { - let style = if newline_index < session.current_index { - // Already typed - check if correct or incorrect - if newline_index < session.inputs.len() { - let input = &session.inputs[newline_index]; - if input.is_correct { - Style::default().fg(Color::DarkGray) - } else { - Style::default().fg(Color::Red) - } - } else { - Style::default().fg(Color::DarkGray) - } - } else if newline_index == session.current_index { - // Next character to type - highlight - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) - } else { - // Remaining - Style::default().fg(Color::White) - }; - - spans.push(Span::styled("↵".to_string(), style)); - } - } - - result_lines.push(Line::from(spans)); - } - - result_lines -} - -/// Rendu de l'interface principale -#[allow(clippy::too_many_arguments)] -pub fn render( - f: &mut Frame, - session: &TypingSession, - wpm: f64, - accuracy: f64, - keyboard_visible: bool, - keyboard_layout: &AzertyLayout, - analytics: &Option, - keyboard_config: &KeyboardConfig, - lesson_name: &str, -) { - let terminal_height = f.area().height; - - // Dynamic constraints based on keyboard visibility and terminal size - // New layout: Header -> Stats -> Content -> Keyboard -> Spacer -> Instructions - let constraints = if keyboard_visible { - if terminal_height >= 28 { - // Full keyboard with shift indicators - vec![ - Constraint::Length(3), // Header - Constraint::Length(3), // Stats (moved after header) - Constraint::Length(14), // Content (7 + 7 lines with padding) - Constraint::Length(12), // Keyboard (follows content) - Constraint::Min(0), // Spacer (absorbs remaining space) - Constraint::Length(3), // Instructions (bottom) - ] - } else if terminal_height >= 23 { - // Full keyboard without shift line - vec![ - Constraint::Length(3), // Header - Constraint::Length(3), // Stats (moved after header) - Constraint::Length(14), // Content (7 + 7 lines with padding) - Constraint::Length(10), // Keyboard (follows content) - Constraint::Min(0), // Spacer - Constraint::Length(3), // Instructions (bottom) - ] - } else { - // Compact keyboard - vec![ - Constraint::Length(3), // Header - Constraint::Length(3), // Stats (moved after header) - Constraint::Length(14), // Content (7 + 7 lines with padding) - Constraint::Length(3), // Keyboard (compact) - Constraint::Min(0), // Spacer - Constraint::Length(3), // Instructions (bottom) - ] - } - } else { - // Layout without keyboard - vec![ - Constraint::Length(3), // Header - Constraint::Length(3), // Stats (moved after header) - Constraint::Min(8), // Content (can expand when no keyboard) - Constraint::Length(3), // Instructions (bottom) - ] - }; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints(constraints) - .split(f.area()); - - let mut chunk_idx = 0; - - // Header - render_header(f, chunks[chunk_idx], lesson_name); - chunk_idx += 1; - - // Stats (moved after header) - render_stats( - f, - chunks[chunk_idx], - wpm, - accuracy, - session.remaining_time(), - ); - chunk_idx += 1; - - // Content area (typing area) - render_typing_area(f, chunks[chunk_idx], session); - chunk_idx += 1; - - // Keyboard (follows content with margin) - if keyboard_visible { - let next_char = session.content.chars().nth(session.current_index); - - if terminal_height < 20 { - render_keyboard_compact(f, chunks[chunk_idx], keyboard_layout, next_char); - } else { - render_keyboard( - f, - chunks[chunk_idx], - keyboard_layout, - next_char, - analytics, - keyboard_config, - ); - } - chunk_idx += 1; // Move to spacer - chunk_idx += 1; // Move to instructions - } - // When keyboard is not visible, chunk_idx is already at instructions position - - // Instructions (bottom) - render_instructions(f, chunks[chunk_idx]); -} - -/// Rendu du header -fn render_header(f: &mut Frame, area: Rect, lesson_name: &str) { - let title_text = format!("TYPER CLI - {}", lesson_name); - let title = Paragraph::new(title_text) - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); - - f.render_widget(title, area); -} - -/// Create multiline colored input display -fn create_colored_input_multiline(session: &TypingSession, width: usize) -> Vec> { - let effective_width = width.saturating_sub(4); - let mut lines = Vec::new(); - let mut current_line_spans = Vec::new(); - let mut current_line_width = 0; - - for input in session.inputs.iter() { - let color = if input.is_correct { - Color::Green - } else { - Color::Red - }; - let display_char = if input.typed == ' ' { - '·' - } else if input.typed == '\n' { - '↵' - } else { - input.typed - }; - - // Check if adding this character would exceed line width - if current_line_width >= effective_width { - lines.push(Line::from(current_line_spans.clone())); - current_line_spans.clear(); - current_line_width = 0; - } - - current_line_spans.push(Span::styled( - display_char.to_string(), - Style::default().fg(color), - )); - current_line_width += 1; - } - - // Add cursor to current line - if !session.is_complete() { - current_line_spans.push(Span::styled( - "█", - Style::default() - .fg(Color::White) - .add_modifier(Modifier::SLOW_BLINK), - )); - } - - // Push final line - if !current_line_spans.is_empty() { - lines.push(Line::from(current_line_spans)); - } - - // Return only the first 3 lines (sliding window) - lines.into_iter().take(3).collect() -} - -/// Rendu de la zone de typing (multiline with sliding window) -fn render_typing_area(f: &mut Frame, area: Rect, session: &TypingSession) { - let terminal_width = area.width as usize; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(7), // Expected text (3 lines + borders + padding) - Constraint::Length(7), // User input (3 lines + borders + padding) - ]) - .split(area); - - // Expected text - 3-line sliding window with character-level styling - let window = extract_visible_window(session, terminal_width); - let expected_lines = create_styled_expected_text(session, &window); - - let expected_text = Paragraph::new(expected_lines).block( - Block::default() - .title("Text to type") - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::new(2, 2, 1, 0)), - ); - - f.render_widget(expected_text, chunks[0]); - - // User input - multiline colored display - let user_input_lines = create_colored_input_multiline(session, terminal_width); - let input_widget = Paragraph::new(user_input_lines).block( - Block::default() - .title("Your input") - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::new(2, 2, 1, 0)), - ); - - f.render_widget(input_widget, chunks[1]); -} - -/// Rendu des statistiques -fn render_stats( - f: &mut Frame, - area: Rect, - wpm: f64, - accuracy: f64, - remaining: std::time::Duration, -) { - let stats_text = format!( - " WPM: {:.0} │ Accuracy: {:.1}% │ Time Remaining: {:02}:{:02}", - wpm, - accuracy, - remaining.as_secs() / 60, - remaining.as_secs() % 60 - ); - - let stats = Paragraph::new(stats_text) - .style(Style::default().fg(Color::Yellow)) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); - - f.render_widget(stats, area); -} - -/// Rendu des instructions -fn render_instructions(f: &mut Frame, area: Rect) { - let instructions = vec![ - Line::from(""), - Line::from(Span::styled( - "ESC to quit", - Style::default().fg(Color::Gray), - )), - ]; - - let instructions_widget = Paragraph::new(instructions).alignment(Alignment::Center); - - f.render_widget(instructions_widget, area); -} - -/// Rendu du menu de sélection de leçon -pub fn render_menu( - f: &mut Frame, - lessons: &[Lesson], - selected: usize, - scroll_offset: usize, - category_name: Option<&str>, -) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(10), // Menu - Constraint::Length(3), // Instructions - ]) - .split(f.area()); - - // Header - let header_text = if let Some(name) = category_name { - format!("TYPER CLI - {} Lessons", name) - } else { - "TYPER CLI - Select a Lesson".to_string() - }; - let header = Paragraph::new(header_text) - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); - f.render_widget(header, chunks[0]); - - // Calculate visible area height (minus borders and padding) - let menu_area_height = chunks[1].height.saturating_sub(2) as usize; - - // Build lesson items with category-specific grouping separators - let mut all_items: Vec = Vec::new(); - - match category_name { - Some("Languages") => { - // Group lessons by language - use crate::content::bigram::Language; - use crate::content::lesson::LessonType; - - let mut current_language: Option = None; - - for (i, lesson) in lessons.iter().enumerate() { - // Detect language from lesson type - let lesson_language = match &lesson.lesson_type { - LessonType::Bigram { - language: Some(lang), - .. - } - | LessonType::Trigram { language: lang, .. } - | LessonType::CommonWords { language: lang, .. } => Some(*lang), - _ => None, - }; - - // Add separator when language changes - if lesson_language != current_language && lesson_language.is_some() { - current_language = lesson_language; - - // Add blank line before separator (except for first group) - if i > 0 { - all_items.push(ListItem::new(Line::from(""))); - } - - // Add language separator - let language_name = match current_language { - Some(Language::French) => "FRENCH", - Some(Language::English) => "ENGLISH", - None => "", - }; - - all_items.push(ListItem::new(Line::from(Span::styled( - format!("─── {} ───", language_name), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )))); - } - - // Add lesson item - let style = if i == selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - let prefix = if i == selected { "▶ " } else { " " }; - let content = format!("{}{}. {}", prefix, i + 1, lesson.title); - - all_items.push(ListItem::new(Line::from(Span::styled(content, style)))); - } - } - Some("Finger Training") => { - // Group lessons by finger pair - use crate::content::lesson::{FingerPairType, LessonType}; - - let mut current_finger_pair: Option = None; - - for (i, lesson) in lessons.iter().enumerate() { - // Detect finger pair from lesson type - let lesson_finger_pair = match &lesson.lesson_type { - LessonType::FingerPair { finger_pair, .. } => Some(*finger_pair), - _ => None, - }; - - // Add separator when finger pair changes - if lesson_finger_pair != current_finger_pair && lesson_finger_pair.is_some() { - current_finger_pair = lesson_finger_pair; - - // Add blank line before separator (except for first group) - if i > 0 { - all_items.push(ListItem::new(Line::from(""))); - } - - // Add finger pair separator - let finger_name = match current_finger_pair { - Some(FingerPairType::Pinky) => "PINKY FINGERS", - Some(FingerPairType::Ring) => "RING FINGERS", - Some(FingerPairType::Middle) => "MIDDLE FINGERS", - Some(FingerPairType::Index) => "INDEX FINGERS", - None => "", - }; - - all_items.push(ListItem::new(Line::from(Span::styled( - format!("─── {} ───", finger_name), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )))); - } - - // Add lesson item - let style = if i == selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - let prefix = if i == selected { "▶ " } else { " " }; - let content = format!("{}{}. {}", prefix, i + 1, lesson.title); - - all_items.push(ListItem::new(Line::from(Span::styled(content, style)))); - } - } - Some("Code") => { - // Group lessons by type (code bigrams vs language-specific symbols) - use crate::content::bigram::BigramType; - use crate::content::code_symbols::ProgrammingLanguage; - use crate::content::lesson::LessonType; - - #[derive(Debug, PartialEq, Clone, Copy)] - enum CodeGroupType { - CodeBigrams, - TypeScript, - Rust, - Python, - } - - let mut current_group: Option = None; - - for (i, lesson) in lessons.iter().enumerate() { - // Detect code group type from lesson type - let lesson_group = match &lesson.lesson_type { - LessonType::Bigram { - bigram_type: BigramType::Code, - .. - } => Some(CodeGroupType::CodeBigrams), - LessonType::CodeSymbols { - language: ProgrammingLanguage::TypeScript, - .. - } => Some(CodeGroupType::TypeScript), - LessonType::CodeSymbols { - language: ProgrammingLanguage::Rust, - .. - } => Some(CodeGroupType::Rust), - LessonType::CodeSymbols { - language: ProgrammingLanguage::Python, - .. - } => Some(CodeGroupType::Python), - _ => None, - }; - - // Add separator when group changes - if lesson_group != current_group && lesson_group.is_some() { - current_group = lesson_group; - - // Add blank line before separator (except for first group) - if i > 0 { - all_items.push(ListItem::new(Line::from(""))); - } - - // Add group separator - let group_name = match current_group { - Some(CodeGroupType::CodeBigrams) => "CODE PATTERNS", - Some(CodeGroupType::TypeScript) => "TYPESCRIPT", - Some(CodeGroupType::Rust) => "RUST", - Some(CodeGroupType::Python) => "PYTHON", - None => "", - }; - - all_items.push(ListItem::new(Line::from(Span::styled( - format!("─── {} ───", group_name), - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - )))); - } - - // Add lesson item - let style = if i == selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - let prefix = if i == selected { "▶ " } else { " " }; - let content = format!("{}{}. {}", prefix, i + 1, lesson.title); - - all_items.push(ListItem::new(Line::from(Span::styled(content, style)))); - } - } - Some("Custom") => { - // Custom lessons with helpful message when empty - if lessons.is_empty() { - // Display instruction message when no custom lessons found - all_items.push(ListItem::new(Line::from(""))); - all_items.push(ListItem::new(Line::from(Span::styled( - "No custom lessons found.", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )))); - all_items.push(ListItem::new(Line::from(""))); - all_items.push(ListItem::new(Line::from(Span::styled( - "Create custom lessons by adding markdown files to:", - Style::default().fg(Color::White), - )))); - all_items.push(ListItem::new(Line::from(""))); - all_items.push(ListItem::new(Line::from(Span::styled( - " • ~/.config/typer-cli/custom/", - Style::default().fg(Color::Cyan), - )))); - all_items.push(ListItem::new(Line::from(Span::styled( - " • ./custom/ (current directory)", - Style::default().fg(Color::Cyan), - )))); - all_items.push(ListItem::new(Line::from(""))); - all_items.push(ListItem::new(Line::from(Span::styled( - "Example file format:", - Style::default().fg(Color::White), - )))); - all_items.push(ListItem::new(Line::from(""))); - all_items.push(ListItem::new(Line::from(Span::styled( - " ---", - Style::default().fg(Color::Gray), - )))); - all_items.push(ListItem::new(Line::from(Span::styled( - " title: My Lesson", - Style::default().fg(Color::Gray), - )))); - all_items.push(ListItem::new(Line::from(Span::styled( - " description: Practice custom content", - Style::default().fg(Color::Gray), - )))); - all_items.push(ListItem::new(Line::from(Span::styled( - " ---", - Style::default().fg(Color::Gray), - )))); - all_items.push(ListItem::new(Line::from(""))); - all_items.push(ListItem::new(Line::from(Span::styled( - " Your custom text to practice goes here.", - Style::default().fg(Color::Gray), - )))); - all_items.push(ListItem::new(Line::from(""))); - all_items.push(ListItem::new(Line::from(Span::styled( - "Restart the app after adding files.", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::ITALIC), - )))); - } else { - // Display custom lessons normally - for (i, lesson) in lessons.iter().enumerate() { - let style = if i == selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - let prefix = if i == selected { "▶ " } else { " " }; - let content = format!("{}{}. {}", prefix, i + 1, lesson.title); - - all_items.push(ListItem::new(Line::from(Span::styled(content, style)))); - } - } - } - _ => { - // Standard rendering for other categories (Key Training, Adaptive) - for (i, lesson) in lessons.iter().enumerate() { - let style = if i == selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - let prefix = if i == selected { "▶ " } else { " " }; - let content = format!("{}{}. {}", prefix, i + 1, lesson.title); - - all_items.push(ListItem::new(Line::from(Span::styled(content, style)))); - } - } - } - - // Calculate visible slice based on scroll offset - let total_items = all_items.len(); - let visible_start = scroll_offset.min(total_items.saturating_sub(1)); - let visible_end = (visible_start + menu_area_height).min(total_items); - let visible_items: Vec = all_items - .into_iter() - .skip(visible_start) - .take(visible_end - visible_start) - .collect(); - - // Add scroll indicator to title - let scroll_indicator = if total_items > menu_area_height { - format!( - " (showing {}-{} of {})", - visible_start + 1, - visible_end, - total_items - ) - } else { - String::new() - }; - - let title = format!("Typing Lessons{}", scroll_indicator); - - let list = List::new(visible_items).block( - Block::default() - .title(title) - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), - ); - - f.render_widget(list, chunks[1]); - - // Instructions - let instructions = vec![ - Line::from(""), - Line::from(Span::styled( - "Use ↑/↓ or j/k to navigate • Press Enter/Space or 1-9 to select • ESC to go back", - Style::default().fg(Color::Gray), - )), - ]; - - let instructions_widget = Paragraph::new(instructions).alignment(Alignment::Center); - - f.render_widget(instructions_widget, chunks[2]); -} - -/// Rendu du menu de sélection de durée -pub fn render_duration_menu(f: &mut Frame, selected: usize) { - use crate::engine::SessionDuration; - - let durations = SessionDuration::all(); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(10), // Duration list - Constraint::Length(3), // Instructions - ]) - .split(f.area()); - - // Header - let header = Paragraph::new("TYPER CLI - Select Session Duration") - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); - f.render_widget(header, chunks[0]); - - // Duration list - let items: Vec = durations - .iter() - .enumerate() - .map(|(i, duration): (usize, &SessionDuration)| { - let style = if i == selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - let prefix = if i == selected { "▶ " } else { " " }; - let content = format!("{}{}", prefix, duration.label()); - - ListItem::new(Line::from(Span::styled(content, style))) - }) - .collect(); - - let list = List::new(items).block( - Block::default() - .title("Duration") - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), - ); - - f.render_widget(list, chunks[1]); - - // Instructions - let instructions = vec![ - Line::from(""), - Line::from(Span::styled( - "Use ↑/↓ or j/k to navigate • Press Enter/Space to start • ESC to go back", - Style::default().fg(Color::Gray), - )), - ]; - - let instructions_widget = Paragraph::new(instructions).alignment(Alignment::Center); - - f.render_widget(instructions_widget, chunks[2]); -} - -/// Render lesson type category menu -pub fn render_lesson_type_menu( - f: &mut Frame, - categories: &[crate::content::LessonCategory], - selected: usize, - layout_variant: crate::keyboard::LayoutVariant, -) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(10), // Menu - Constraint::Length(3), // Instructions - ]) - .split(f.area()); - - // Header - let header = Paragraph::new("TYPER CLI - Select Lesson Type") - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); - f.render_widget(header, chunks[0]); - - // Build category menu items (two-line format) - let mut items: Vec = Vec::new(); - - for (i, category) in categories.iter().enumerate() { - let is_selected = i == selected; - - // First line: number and name - let name_style = if is_selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(category.color) - }; - - let prefix = if is_selected { "▶ " } else { " " }; - let name_line = format!("{}{}. {}", prefix, i + 1, category.name); - - items.push(ListItem::new(Line::from(Span::styled( - name_line, name_style, - )))); - - // Second line: description - let description_line = format!(" {}", category.description); - items.push(ListItem::new(Line::from(Span::styled( - description_line, - Style::default().fg(Color::Gray), - )))); - - // Blank line between categories (except after last) - if i < categories.len() - 1 { - items.push(ListItem::new(Line::from(""))); - } - } - - let list = List::new(items).block( - Block::default() - .title("Lesson Categories") - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), - ); - - f.render_widget(list, chunks[1]); - - // Instructions - let layout_name = match layout_variant { - crate::keyboard::LayoutVariant::Mac => "Mac", - crate::keyboard::LayoutVariant::Pc => "PC", - }; - let hint = format!( - "↑/↓ j/k navigate • Enter/1-5 select • [s] Statistics • [p] Preferences (Layout: {}) • ESC quit", - layout_name - ); - let instructions = vec![ - Line::from(""), - Line::from(Span::styled(hint, Style::default().fg(Color::Gray))), - ]; - - let instructions_widget = Paragraph::new(instructions).alignment(Alignment::Center); - - f.render_widget(instructions_widget, chunks[2]); -} - -/// Rendu de l'écran de fin -pub fn render_results( - f: &mut Frame, - wpm: f64, - accuracy: f64, - duration: std::time::Duration, - error_count: usize, -) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(4) - .constraints([ - Constraint::Length(3), // Title - Constraint::Length(8), // Results - Constraint::Length(2), // Instructions - ]) - .split(f.area()); - - // Titre - let title = Paragraph::new("Session Complete!") - .style( - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); - - f.render_widget(title, chunks[0]); - - // Résultats - let results_text = vec![ - Line::from(""), - Line::from(Span::styled( - format!("WPM: {:.1}", wpm), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - format!("Accuracy: {:.1}%", accuracy), - Style::default().fg(Color::Green), - )), - Line::from(Span::styled( - format!("Errors: {}", error_count), - Style::default().fg(Color::Red), - )), - Line::from(Span::styled( - format!( - "Time: {:02}:{:02}", - duration.as_secs() / 60, - duration.as_secs() % 60 - ), - Style::default().fg(Color::Yellow), - )), - ]; - - let results = Paragraph::new(results_text) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); - - f.render_widget(results, chunks[1]); - - // Instructions - let instructions = Paragraph::new("Press ESC to return to menu • Press 'r' to restart") - .style(Style::default().fg(Color::Gray)) - .alignment(Alignment::Center); - - f.render_widget(instructions, chunks[2]); -} - -/// Render statistics and performance analytics page -pub fn render_statistics( - f: &mut Frame, - stats: &Stats, - keyboard_layout: &AzertyLayout, - keyboard_config: &KeyboardConfig, -) { - // Check if we have analytics data - if let Some(analytics) = &stats.adaptive_analytics { - render_statistics_with_data(f, stats, analytics, keyboard_layout, keyboard_config); - } else { - render_statistics_placeholder(f); - } -} - -/// Render statistics placeholder when no data exists -fn render_statistics_placeholder(f: &mut Frame) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(4) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(10), // Content - Constraint::Length(3), // Instructions - ]) - .split(f.area()); - - // Header - let header = Paragraph::new("TYPER CLI - Statistics") - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - - f.render_widget(header, chunks[0]); - - // Content - placeholder message - let content_lines = vec![ - Line::from(""), - Line::from(""), - Line::from(Span::styled( - "No Statistics Available", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from("Complete your first typing session to"), - Line::from("start tracking your performance!"), - Line::from(""), - Line::from(""), - Line::from("Progress tracking includes:"), - Line::from(" • Per-key accuracy and speed"), - Line::from(" • Weakness identification"), - Line::from(" • Mastery level progression"), - Line::from(" • Common mistype patterns"), - ]; - - let content = Paragraph::new(content_lines) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - - f.render_widget(content, chunks[1]); - - // Instructions - let instructions = Paragraph::new("h: History d: Details e: Export ESC to return to menu") - .style(Style::default().fg(Color::Gray)) - .alignment(Alignment::Center); - - f.render_widget(instructions, chunks[2]); -} - -/// Render statistics with actual data -fn render_statistics_with_data( - f: &mut Frame, - stats: &Stats, - analytics: &AdaptiveAnalytics, - keyboard_layout: &AzertyLayout, - keyboard_config: &KeyboardConfig, -) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(20), // Content - Constraint::Length(3), // Instructions - ]) - .split(f.area()); - - // Header - let header = Paragraph::new("TYPER CLI - Statistics & Performance") - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); - - f.render_widget(header, chunks[0]); - - // Content area - split horizontally (40% left / 60% right) - let content_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) - .split(chunks[1]); - - // Left column - split vertically for different stats sections - let left_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(8), // Overall stats - Constraint::Length(20), // Mastery breakdown (4 levels x 2 lines + spacing + borders) - Constraint::Length(8), // Weaknesses (reduced by 2 lines) - Constraint::Min(10), // Common mistypes (increased) - ]) - .split(content_chunks[0]); - - // Render left column sections - render_overall_stats_block(f, stats, analytics, left_chunks[0]); - render_mastery_breakdown(f, analytics, left_chunks[1]); - render_weaknesses_list(f, analytics, left_chunks[2]); - render_common_mistypes(f, analytics, left_chunks[3]); - - // Render keyboard heatmap on the right - render_keyboard_with_heatmap( - f, - keyboard_layout, - keyboard_config, - analytics, - content_chunks[1], - ); - - // Instructions - let session_count = analytics.total_sessions; - let instructions_text = format!( - "h: History d: Details e: Export ESC to return • Analyzing {} session{}", - session_count, - if session_count == 1 { "" } else { "s" } - ); - - let instructions = Paragraph::new(instructions_text) - .style(Style::default().fg(Color::Gray)) - .alignment(Alignment::Center); - - f.render_widget(instructions, chunks[2]); -} - -/// Render overall performance statistics -fn render_overall_stats_block( - f: &mut Frame, - stats: &Stats, - analytics: &AdaptiveAnalytics, - area: Rect, -) { - let session_count = stats.session_count(); - let total_keystrokes = analytics.total_keystrokes; - let avg_wpm = stats.average_wpm(); - let avg_accuracy = stats.average_accuracy(); - - let stats_lines = vec![ - Line::from(vec![ - Span::raw("Sessions: "), - Span::styled( - format!("{}", session_count), - Style::default().fg(Color::Yellow), - ), - ]), - Line::from(vec![ - Span::raw("Total Keys: "), - Span::styled( - format!("{}", total_keystrokes), - Style::default().fg(Color::Yellow), - ), - ]), - Line::from(vec![ - Span::raw("Avg WPM: "), - Span::styled( - format!("{:.1}", avg_wpm), - Style::default().fg(Color::Yellow), - ), - ]), - Line::from(vec![ - Span::raw("Avg Accuracy: "), - Span::styled( - format!("{:.1}%", avg_accuracy), - Style::default().fg(Color::Yellow), - ), - ]), - ]; - - let block = Paragraph::new(stats_lines) - .block( - Block::default() - .title("Overall Performance") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), - ) - .alignment(Alignment::Left); - - f.render_widget(block, area); -} - -/// Render mastery level breakdown -fn render_mastery_breakdown(f: &mut Frame, analytics: &AdaptiveAnalytics, area: Rect) { - // Collect keys per mastery level - let mut keys_by_level: HashMap> = HashMap::new(); - for (key, stats) in &analytics.key_stats { - keys_by_level - .entry(stats.mastery_level) - .or_default() - .push(*key); - } - - // Sort keys alphabetically for each level - for keys in keys_by_level.values_mut() { - keys.sort(); - } - - // Get sorted keys for each level - let mastered_keys = keys_by_level - .get(&MasteryLevel::Mastered) - .cloned() - .unwrap_or_default(); - let proficient_keys = keys_by_level - .get(&MasteryLevel::Proficient) - .cloned() - .unwrap_or_default(); - let learning_keys = keys_by_level - .get(&MasteryLevel::Learning) - .cloned() - .unwrap_or_default(); - let beginner_keys = keys_by_level - .get(&MasteryLevel::Beginner) - .cloned() - .unwrap_or_default(); - - // Helper function to format key list - let format_key_list = |keys: &[char]| -> String { - if keys.is_empty() { - return String::from(" (none)"); - } - let display_keys: Vec = keys.iter().map(|k| k.to_string()).collect(); - format!(" {}", display_keys.join(" ")) - }; - - let mastery_lines = vec![ - Line::from(vec![ - Span::styled("■ ", Style::default().fg(Color::Green)), - Span::styled("Mastered: ", Style::default().fg(Color::Green)), - Span::styled( - format!("{} keys", mastered_keys.len()), - Style::default().fg(Color::Green), - ), - ]), - Line::from(format_key_list(&mastered_keys)), - Line::from(""), - Line::from(vec![ - Span::styled("■ ", Style::default().fg(Color::Yellow)), - Span::styled("Proficient: ", Style::default().fg(Color::Yellow)), - Span::styled( - format!("{} keys", proficient_keys.len()), - Style::default().fg(Color::Yellow), - ), - ]), - Line::from(format_key_list(&proficient_keys)), - Line::from(""), - Line::from(vec![ - Span::styled("■ ", Style::default().fg(Color::LightRed)), - Span::styled("Learning: ", Style::default().fg(Color::LightRed)), - Span::styled( - format!("{} keys", learning_keys.len()), - Style::default().fg(Color::LightRed), - ), - ]), - Line::from(format_key_list(&learning_keys)), - Line::from(""), - Line::from(vec![ - Span::styled("■ ", Style::default().fg(Color::Blue)), - Span::styled("Beginner: ", Style::default().fg(Color::Blue)), - Span::styled( - format!("{} keys", beginner_keys.len()), - Style::default().fg(Color::Blue), - ), - ]), - Line::from(format_key_list(&beginner_keys)), - ]; - - let block = Paragraph::new(mastery_lines) - .block( - Block::default() - .title("Mastery Levels") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), - ) - .alignment(Alignment::Left); - - f.render_widget(block, area); -} - -/// Render top weaknesses list -fn render_weaknesses_list(f: &mut Frame, analytics: &AdaptiveAnalytics, area: Rect) { - let mut weak_keys: Vec<_> = analytics - .key_stats - .iter() - .filter(|(_, stats)| stats.accuracy() < 80.0 && stats.total_attempts >= 5) - .collect(); - - weak_keys.sort_by(|a, b| { - a.1.accuracy() - .partial_cmp(&b.1.accuracy()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - let mut weakness_lines = vec![]; - - if weak_keys.is_empty() { - weakness_lines.push(Line::from(Span::styled( - "No weaknesses - Great job!", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ))); - } else { - for (i, (key, stats)) in weak_keys.iter().take(10).enumerate() { - let accuracy = stats.accuracy(); - let color = if accuracy < 50.0 { - Color::Red - } else if accuracy < 70.0 { - Color::LightRed - } else { - Color::Yellow - }; - - weakness_lines.push(Line::from(vec![ - Span::raw(format!("#{} ", i + 1)), - Span::styled( - format!("'{}' - {:.0}% acc", key, accuracy), - Style::default().fg(color), - ), - Span::raw(format!(" ({} errors)", stats.error_count)), - if i == 0 { - Span::styled( - " ← WEAKEST", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ) - } else { - Span::raw("") - }, - ])); - } - } - - let block = Paragraph::new(weakness_lines) - .block( - Block::default() - .title("Weakest Keys (Lower = More Practice)") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), - ) - .alignment(Alignment::Left); - - f.render_widget(block, area); -} - -/// Render common mistype patterns -fn render_common_mistypes(f: &mut Frame, analytics: &AdaptiveAnalytics, area: Rect) { - let mut all_mistypes = Vec::new(); - for (expected, key_stats) in &analytics.key_stats { - for (typed, count) in &key_stats.mistype_map { - all_mistypes.push((expected, typed, count)); - } - } - - all_mistypes.sort_by(|a, b| b.2.cmp(a.2)); - - let mut mistype_lines = vec![]; - - if all_mistypes.is_empty() || all_mistypes.len() < 5 { - mistype_lines.push(Line::from(Span::styled( - "Insufficient data", - Style::default().fg(Color::Gray), - ))); - } else { - // Split into two columns: items 1-5 on left, 6-10 on right - let top_10: Vec<_> = all_mistypes.iter().take(10).collect(); - let max_rows = top_10.len().div_ceil(2); - - for i in 0..max_rows { - let left_item = top_10.get(i); - let right_item = top_10.get(i + max_rows); - - let mut spans = Vec::new(); - - // Left column - if let Some((expected, typed, count)) = left_item { - spans.push(Span::raw(format!( - "'{}' → '{}': {} times", - expected, typed, count - ))); - } - - // Spacing between columns - if right_item.is_some() { - spans.push(Span::raw(" ")); - } - - // Right column - if let Some((expected, typed, count)) = right_item { - spans.push(Span::raw(format!( - "'{}' → '{}': {} times", - expected, typed, count - ))); - } - - mistype_lines.push(Line::from(spans)); - } - } - - let block = Paragraph::new(mistype_lines) - .block( - Block::default() - .title("Common Mistypes") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), - ) - .alignment(Alignment::Left); - - f.render_widget(block, area); -} - -/// Render keyboard for statistics page -fn render_keyboard_with_heatmap( - f: &mut Frame, - keyboard_layout: &AzertyLayout, - keyboard_config: &KeyboardConfig, - analytics: &AdaptiveAnalytics, - area: Rect, -) { - // Create a custom config for statistics context - let mut stats_config = keyboard_config.clone(); - stats_config.show_heatmap = true; // Always show heatmap in stats - stats_config.show_finger_colors = false; // No finger colors in stats (heatmap takes priority) - stats_config.show_footer_shortcuts = false; // Don't show footer (we have our own instructions) - - // render_keyboard signature: (f, area, layout, next_char, analytics, config) - render_keyboard( - f, - area, - keyboard_layout, - None, // No next char highlighting - &Some(analytics.clone()), - &stats_config, - ); -} - -/// Render analytics history with ASCII charts -pub fn render_analytics_history(f: &mut Frame, stats: &Stats) { - use crate::ui::analytics::{ - calculate_daily_goal_progress, calculate_daily_progress, calculate_monthly_progress, - calculate_weekly_progress, create_daily_wpm_chart, create_goal_progress_display, - create_monthly_wpm_chart, create_weekly_wpm_chart, get_session_chart_stats, - }; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(0), // Content - Constraint::Length(3), // Instructions - ]) - .split(f.area()); - - // Header - let header = Paragraph::new("Analytics History") - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ) - .alignment(Alignment::Center); - f.render_widget(header, chunks[0]); - - // Content area with charts - let content = if stats.sessions.is_empty() { - vec![Line::from(Span::styled( - "No session data available. Complete some lessons first!", - Style::default().fg(Color::Yellow), - ))] - } else { - let mut lines = Vec::new(); - - // Get recent sessions for charting - let recent_sessions = get_session_chart_stats(stats); - - if !recent_sessions.is_empty() { - // Daily Progress Chart - let daily_progress = calculate_daily_progress(&recent_sessions); - if !daily_progress.is_empty() { - let daily_chart = create_daily_wpm_chart(&daily_progress); - lines.extend(daily_chart.into_iter().map(Line::from)); - lines.push(Line::from("")); - } - - // Add spacing - lines.push(Line::from("")); - - // Weekly Progress Chart - let weekly_progress = calculate_weekly_progress(&recent_sessions); - if !weekly_progress.is_empty() { - let weekly_chart = create_weekly_wpm_chart(&weekly_progress); - lines.extend(weekly_chart.into_iter().map(Line::from)); - lines.push(Line::from("")); - } - - // Add spacing - lines.push(Line::from("")); - - // Monthly Progress Chart - let monthly_progress = calculate_monthly_progress(&recent_sessions); - if !monthly_progress.is_empty() { - let monthly_chart = create_monthly_wpm_chart(&monthly_progress); - lines.extend(monthly_chart.into_iter().map(Line::from)); - lines.push(Line::from("")); - } - - // Add spacing - lines.push(Line::from("")); - - // Goal Progress Display - let (current_wpm, progress_percent, status, details) = - calculate_daily_goal_progress(&stats.sessions, 50.0); // Default goal: 50 WPM - let goal_display = create_goal_progress_display( - current_wpm, - 50.0, - progress_percent, - &status, - &details, - ); - lines.extend(goal_display.into_iter().map(Line::from)); - lines.push(Line::from("")); - lines.push(Line::from("")); - - // Add session summary - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - format!("Showing {} most recent sessions", recent_sessions.len()), - Style::default().fg(Color::Gray), - ))); - } else { - lines.push(Line::from("No session data available for charting.")); - } - - lines - }; - - let content_area = chunks[1]; - let content_block = Paragraph::new(content) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), - ) - .alignment(Alignment::Left); - f.render_widget(content_block, content_area); - - // Instructions - let instructions = Paragraph::new("o: Overview d: Details e: Export q: Back to Menu") - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ) - .alignment(Alignment::Center); - f.render_widget(instructions, chunks[2]); -} - -/// Render detailed analytics -pub fn render_analytics_details(f: &mut Frame, stats: &Stats) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(0), // Content - Constraint::Length(3), // Instructions - ]) - .split(f.area()); - - // Header - let header = Paragraph::new("Detailed Analytics") - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ) - .alignment(Alignment::Center); - f.render_widget(header, chunks[0]); - - // Content area - placeholder for now - let content = if let Some(analytics) = &stats.adaptive_analytics { - vec![ - Line::from("Per-Key Performance Details:"), - Line::from("Coming soon - detailed analytics implementation..."), - Line::from(""), - Line::from(format!("Total Keys Tracked: {}", analytics.key_stats.len())), - Line::from(format!( - "Total Bigrams Tracked: {}", - analytics.bigram_stats.len() - )), - Line::from(""), - Line::from("Weakness Detection:"), - Line::from("Coming soon - weakness analysis implementation..."), - ] - } else { - vec![Line::from(Span::styled( - "No detailed analytics available. Complete more sessions first!", - Style::default().fg(Color::Yellow), - ))] - }; - - let content_area = chunks[1]; - let content_block = Paragraph::new(content) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ) - .alignment(Alignment::Left); - f.render_widget(content_block, content_area); - - // Instructions - let instructions = Paragraph::new("o: Overview h: History e: Export q: Back to Menu") - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ) - .alignment(Alignment::Center); - f.render_widget(instructions, chunks[2]); -} - -/// Render analytics export options -pub fn render_analytics_export(f: &mut Frame, stats: &Stats, export_message: Option<&str>) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(0), // Content - Constraint::Length(3), // Instructions - ]) - .split(f.area()); - - // Header - let header = Paragraph::new("Export Analytics") - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ) - .alignment(Alignment::Center); - f.render_widget(header, chunks[0]); - - // Content area - let mut content = vec![ - Line::from("Export Options:"), - Line::from(""), - Line::from("1. Export Complete Data (JSON)"), - Line::from(" Export all sessions, key stats, and bigram data"), - Line::from(""), - Line::from(format!("Total Sessions: {}", stats.sessions.len())), - Line::from(format!( - "Total Keystrokes: {}", - stats - .adaptive_analytics - .as_ref() - .map(|a| a.total_keystrokes.to_string()) - .unwrap_or_else(|| "N/A".to_string()) - )), - ]; - - if let Some(msg) = export_message { - content.push(Line::from("")); - let color = if msg.starts_with("Export failed") { - Color::Red - } else { - Color::Green - }; - content.push(Line::from(Span::styled( - msg.to_string(), - Style::default().fg(color), - ))); - } - - let content_area = chunks[1]; - let content_block = Paragraph::new(content) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ) - .alignment(Alignment::Left); - f.render_widget(content_block, content_area); - - // Instructions - let instructions = - Paragraph::new("1: Export JSON o: Overview h: History d: Details q: Back to Menu") - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ) - .alignment(Alignment::Center); - f.render_widget(instructions, chunks[2]); -} - -/// Render the settings screen for layout selection -pub fn render_settings( - f: &mut Frame, - selected_layout: usize, - current_variant: crate::keyboard::LayoutVariant, -) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(3), - Constraint::Min(6), - Constraint::Length(3), - ]) - .split(f.area()); - - let header = Paragraph::new("TYPER CLI - Settings") - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); - f.render_widget(header, chunks[0]); - - let layouts = [ - ("AZERTY Mac", crate::keyboard::LayoutVariant::Mac), - ("AZERTY PC", crate::keyboard::LayoutVariant::Pc), - ]; - - let items: Vec = layouts - .iter() - .enumerate() - .map(|(i, (name, variant))| { - let is_selected = i == selected_layout; - let is_active = *variant == current_variant; - let prefix = if is_selected { "▶ " } else { " " }; - let check = if is_active { " [✓]" } else { "" }; - let label = format!("{}{}{}", prefix, name, check); - let style = if is_selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - ListItem::new(Line::from(Span::styled(label, style))) - }) - .collect(); - - let list = List::new(items).block( - Block::default() - .title("Keyboard Layout") - .borders(Borders::ALL) - .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), - ); - f.render_widget(list, chunks[1]); - - let instructions = vec![ - Line::from(""), - Line::from(Span::styled( - "Use ↑/↓ or j/k to navigate • Enter to apply and save • ESC to cancel", - Style::default().fg(Color::Gray), - )), - ]; - f.render_widget( - Paragraph::new(instructions).alignment(Alignment::Center), - chunks[2], - ); -} diff --git a/src/ui/render/analytics_screen.rs b/src/ui/render/analytics_screen.rs new file mode 100644 index 0000000..e38f168 --- /dev/null +++ b/src/ui/render/analytics_screen.rs @@ -0,0 +1,337 @@ +use super::*; + +/// Render analytics history with ASCII charts +pub fn render_analytics_history(f: &mut Frame, stats: &Stats) { + use crate::ui::analytics::{ + calculate_daily_goal_progress, calculate_daily_progress, calculate_monthly_progress, + calculate_weekly_progress, create_daily_wpm_chart, create_goal_progress_display, + create_monthly_wpm_chart, create_weekly_wpm_chart, get_session_chart_stats, + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Content + Constraint::Length(3), // Instructions + ]) + .split(f.area()); + + // Header + let header = Paragraph::new("Analytics History") + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .alignment(Alignment::Center); + f.render_widget(header, chunks[0]); + + // Content area with charts + let content = if stats.sessions.is_empty() { + vec![Line::from(Span::styled( + "No session data available. Complete some lessons first!", + Style::default().fg(Color::Yellow), + ))] + } else { + let mut lines = Vec::new(); + + // Get recent sessions for charting + let recent_sessions = get_session_chart_stats(stats); + + if !recent_sessions.is_empty() { + // Daily Progress Chart + let daily_progress = calculate_daily_progress(&recent_sessions); + if !daily_progress.is_empty() { + let daily_chart = create_daily_wpm_chart(&daily_progress); + lines.extend(daily_chart.into_iter().map(Line::from)); + lines.push(Line::from("")); + } + + // Add spacing + lines.push(Line::from("")); + + // Weekly Progress Chart + let weekly_progress = calculate_weekly_progress(&recent_sessions); + if !weekly_progress.is_empty() { + let weekly_chart = create_weekly_wpm_chart(&weekly_progress); + lines.extend(weekly_chart.into_iter().map(Line::from)); + lines.push(Line::from("")); + } + + // Add spacing + lines.push(Line::from("")); + + // Monthly Progress Chart + let monthly_progress = calculate_monthly_progress(&recent_sessions); + if !monthly_progress.is_empty() { + let monthly_chart = create_monthly_wpm_chart(&monthly_progress); + lines.extend(monthly_chart.into_iter().map(Line::from)); + lines.push(Line::from("")); + } + + // Add spacing + lines.push(Line::from("")); + + // Goal Progress Display + let (current_wpm, progress_percent, status, details) = + calculate_daily_goal_progress(&stats.sessions, 50.0); // Default goal: 50 WPM + let goal_display = create_goal_progress_display( + current_wpm, + 50.0, + progress_percent, + &status, + &details, + ); + lines.extend(goal_display.into_iter().map(Line::from)); + lines.push(Line::from("")); + lines.push(Line::from("")); + + // Add session summary + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("Showing {} most recent sessions", recent_sessions.len()), + Style::default().fg(Color::Gray), + ))); + } else { + lines.push(Line::from("No session data available for charting.")); + } + + lines + }; + + let content_area = chunks[1]; + let content_block = Paragraph::new(content) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), + ) + .alignment(Alignment::Left); + f.render_widget(content_block, content_area); + + // Instructions + let instructions = Paragraph::new("o: Overview d: Details e: Export q: Back to Menu") + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .alignment(Alignment::Center); + f.render_widget(instructions, chunks[2]); +} + +/// Render detailed analytics +pub fn render_analytics_details(f: &mut Frame, stats: &Stats) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Content + Constraint::Length(3), // Instructions + ]) + .split(f.area()); + + // Header + let header = Paragraph::new("Detailed Analytics") + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .alignment(Alignment::Center); + f.render_widget(header, chunks[0]); + + // Content area - placeholder for now + let content = if let Some(analytics) = &stats.adaptive_analytics { + vec![ + Line::from("Per-Key Performance Details:"), + Line::from("Coming soon - detailed analytics implementation..."), + Line::from(""), + Line::from(format!("Total Keys Tracked: {}", analytics.key_stats.len())), + Line::from(format!( + "Total Bigrams Tracked: {}", + analytics.bigram_stats.len() + )), + Line::from(""), + Line::from("Weakness Detection:"), + Line::from("Coming soon - weakness analysis implementation..."), + ] + } else { + vec![Line::from(Span::styled( + "No detailed analytics available. Complete more sessions first!", + Style::default().fg(Color::Yellow), + ))] + }; + + let content_area = chunks[1]; + let content_block = Paragraph::new(content) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .alignment(Alignment::Left); + f.render_widget(content_block, content_area); + + // Instructions + let instructions = Paragraph::new("o: Overview h: History e: Export q: Back to Menu") + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .alignment(Alignment::Center); + f.render_widget(instructions, chunks[2]); +} + +/// Render analytics export options +pub fn render_analytics_export(f: &mut Frame, stats: &Stats, export_message: Option<&str>) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Content + Constraint::Length(3), // Instructions + ]) + .split(f.area()); + + // Header + let header = Paragraph::new("Export Analytics") + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .alignment(Alignment::Center); + f.render_widget(header, chunks[0]); + + // Content area + let mut content = vec![ + Line::from("Export Options:"), + Line::from(""), + Line::from("1. Export Complete Data (JSON)"), + Line::from(" Export all sessions, key stats, and bigram data"), + Line::from(""), + Line::from(format!("Total Sessions: {}", stats.sessions.len())), + Line::from(format!( + "Total Keystrokes: {}", + stats + .adaptive_analytics + .as_ref() + .map(|a| a.total_keystrokes.to_string()) + .unwrap_or_else(|| "N/A".to_string()) + )), + ]; + + if let Some(msg) = export_message { + content.push(Line::from("")); + let color = if msg.starts_with("Export failed") { + Color::Red + } else { + Color::Green + }; + content.push(Line::from(Span::styled( + msg.to_string(), + Style::default().fg(color), + ))); + } + + let content_area = chunks[1]; + let content_block = Paragraph::new(content) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .alignment(Alignment::Left); + f.render_widget(content_block, content_area); + + // Instructions + let instructions = + Paragraph::new("1: Export JSON o: Overview h: History d: Details q: Back to Menu") + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .alignment(Alignment::Center); + f.render_widget(instructions, chunks[2]); +} + +/// Render the settings screen for layout selection +pub fn render_settings( + f: &mut Frame, + selected_layout: usize, + current_variant: crate::keyboard::LayoutVariant, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), + Constraint::Min(6), + Constraint::Length(3), + ]) + .split(f.area()); + + let header = Paragraph::new("TYPER CLI - Settings") + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + f.render_widget(header, chunks[0]); + + let layouts = [ + ("AZERTY Mac", crate::keyboard::LayoutVariant::Mac), + ("AZERTY PC", crate::keyboard::LayoutVariant::Pc), + ]; + + let items: Vec = layouts + .iter() + .enumerate() + .map(|(i, (name, variant))| { + let is_selected = i == selected_layout; + let is_active = *variant == current_variant; + let prefix = if is_selected { "▶ " } else { " " }; + let check = if is_active { " [✓]" } else { "" }; + let label = format!("{}{}{}", prefix, name, check); + let style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + ListItem::new(Line::from(Span::styled(label, style))) + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .title("Keyboard Layout") + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), + ); + f.render_widget(list, chunks[1]); + + let instructions = vec![ + Line::from(""), + Line::from(Span::styled( + "Use ↑/↓ or j/k to navigate • Enter to apply and save • ESC to cancel", + Style::default().fg(Color::Gray), + )), + ]; + f.render_widget( + Paragraph::new(instructions).alignment(Alignment::Center), + chunks[2], + ); +} diff --git a/src/ui/render/menu.rs b/src/ui/render/menu.rs new file mode 100644 index 0000000..916df64 --- /dev/null +++ b/src/ui/render/menu.rs @@ -0,0 +1,505 @@ +use super::*; + +/// Build a styled menu list item for a lesson, highlighting the selected row. +fn render_lesson_item(index: usize, title: &str, selected: bool) -> ListItem<'static> { + let style = if selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + let prefix = if selected { "▶ " } else { " " }; + let content = format!("{}{}. {}", prefix, index + 1, title); + ListItem::new(Line::from(Span::styled(content, style))) +} + +/// Rendu du menu de sélection de leçon +pub fn render_menu( + f: &mut Frame, + lessons: &[&Lesson], + selected: usize, + scroll_offset: usize, + category_name: Option<&str>, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(10), // Menu + Constraint::Length(3), // Instructions + ]) + .split(f.area()); + + // Header + let header_text = if let Some(name) = category_name { + format!("TYPER CLI - {} Lessons", name) + } else { + "TYPER CLI - Select a Lesson".to_string() + }; + let header = Paragraph::new(header_text) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + f.render_widget(header, chunks[0]); + + // Calculate visible area height (minus borders and padding) + let menu_area_height = chunks[1].height.saturating_sub(2) as usize; + + // Build lesson items with category-specific grouping separators + let mut all_items: Vec = Vec::new(); + + match category_name { + Some("Languages") => { + // Group lessons by language + use crate::content::bigram::Language; + use crate::content::lesson::LessonType; + + let mut current_language: Option = None; + + for (i, lesson) in lessons.iter().enumerate() { + // Detect language from lesson type + let lesson_language = match &lesson.lesson_type { + LessonType::Bigram { + language: Some(lang), + .. + } + | LessonType::Trigram { language: lang, .. } + | LessonType::CommonWords { language: lang, .. } => Some(*lang), + _ => None, + }; + + // Add separator when language changes + if lesson_language != current_language && lesson_language.is_some() { + current_language = lesson_language; + + // Add blank line before separator (except for first group) + if i > 0 { + all_items.push(ListItem::new(Line::from(""))); + } + + // Add language separator + let language_name = match current_language { + Some(Language::French) => "FRENCH", + Some(Language::English) => "ENGLISH", + None => "", + }; + + all_items.push(ListItem::new(Line::from(Span::styled( + format!("─── {} ───", language_name), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )))); + } + + all_items.push(render_lesson_item(i, &lesson.title, i == selected)); + } + } + Some("Finger Training") => { + // Group lessons by finger pair + use crate::content::lesson::{FingerPairType, LessonType}; + + let mut current_finger_pair: Option = None; + + for (i, lesson) in lessons.iter().enumerate() { + // Detect finger pair from lesson type + let lesson_finger_pair = match &lesson.lesson_type { + LessonType::FingerPair { finger_pair, .. } => Some(*finger_pair), + _ => None, + }; + + // Add separator when finger pair changes + if lesson_finger_pair != current_finger_pair && lesson_finger_pair.is_some() { + current_finger_pair = lesson_finger_pair; + + // Add blank line before separator (except for first group) + if i > 0 { + all_items.push(ListItem::new(Line::from(""))); + } + + // Add finger pair separator + let finger_name = match current_finger_pair { + Some(FingerPairType::Pinky) => "PINKY FINGERS", + Some(FingerPairType::Ring) => "RING FINGERS", + Some(FingerPairType::Middle) => "MIDDLE FINGERS", + Some(FingerPairType::Index) => "INDEX FINGERS", + None => "", + }; + + all_items.push(ListItem::new(Line::from(Span::styled( + format!("─── {} ───", finger_name), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )))); + } + + all_items.push(render_lesson_item(i, &lesson.title, i == selected)); + } + } + Some("Code") => { + // Group lessons by type (code bigrams vs language-specific symbols) + use crate::content::bigram::BigramType; + use crate::content::code_symbols::ProgrammingLanguage; + use crate::content::lesson::LessonType; + + #[derive(Debug, PartialEq, Clone, Copy)] + enum CodeGroupType { + CodeBigrams, + TypeScript, + Rust, + Python, + } + + let mut current_group: Option = None; + + for (i, lesson) in lessons.iter().enumerate() { + // Detect code group type from lesson type + let lesson_group = match &lesson.lesson_type { + LessonType::Bigram { + bigram_type: BigramType::Code, + .. + } => Some(CodeGroupType::CodeBigrams), + LessonType::CodeSymbols { + language: ProgrammingLanguage::TypeScript, + .. + } => Some(CodeGroupType::TypeScript), + LessonType::CodeSymbols { + language: ProgrammingLanguage::Rust, + .. + } => Some(CodeGroupType::Rust), + LessonType::CodeSymbols { + language: ProgrammingLanguage::Python, + .. + } => Some(CodeGroupType::Python), + _ => None, + }; + + // Add separator when group changes + if lesson_group != current_group && lesson_group.is_some() { + current_group = lesson_group; + + // Add blank line before separator (except for first group) + if i > 0 { + all_items.push(ListItem::new(Line::from(""))); + } + + // Add group separator + let group_name = match current_group { + Some(CodeGroupType::CodeBigrams) => "CODE PATTERNS", + Some(CodeGroupType::TypeScript) => "TYPESCRIPT", + Some(CodeGroupType::Rust) => "RUST", + Some(CodeGroupType::Python) => "PYTHON", + None => "", + }; + + all_items.push(ListItem::new(Line::from(Span::styled( + format!("─── {} ───", group_name), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )))); + } + + all_items.push(render_lesson_item(i, &lesson.title, i == selected)); + } + } + Some("Custom") => { + // Custom lessons with helpful message when empty + if lessons.is_empty() { + // Display instruction message when no custom lessons found + all_items.push(ListItem::new(Line::from(""))); + all_items.push(ListItem::new(Line::from(Span::styled( + "No custom lessons found.", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )))); + all_items.push(ListItem::new(Line::from(""))); + all_items.push(ListItem::new(Line::from(Span::styled( + "Create custom lessons by adding markdown files to:", + Style::default().fg(Color::White), + )))); + all_items.push(ListItem::new(Line::from(""))); + all_items.push(ListItem::new(Line::from(Span::styled( + " • ~/.config/typer-cli/custom/", + Style::default().fg(Color::Cyan), + )))); + all_items.push(ListItem::new(Line::from(Span::styled( + " • ./custom/ (current directory)", + Style::default().fg(Color::Cyan), + )))); + all_items.push(ListItem::new(Line::from(""))); + all_items.push(ListItem::new(Line::from(Span::styled( + "Example file format:", + Style::default().fg(Color::White), + )))); + all_items.push(ListItem::new(Line::from(""))); + all_items.push(ListItem::new(Line::from(Span::styled( + " ---", + Style::default().fg(Color::Gray), + )))); + all_items.push(ListItem::new(Line::from(Span::styled( + " title: My Lesson", + Style::default().fg(Color::Gray), + )))); + all_items.push(ListItem::new(Line::from(Span::styled( + " description: Practice custom content", + Style::default().fg(Color::Gray), + )))); + all_items.push(ListItem::new(Line::from(Span::styled( + " ---", + Style::default().fg(Color::Gray), + )))); + all_items.push(ListItem::new(Line::from(""))); + all_items.push(ListItem::new(Line::from(Span::styled( + " Your custom text to practice goes here.", + Style::default().fg(Color::Gray), + )))); + all_items.push(ListItem::new(Line::from(""))); + all_items.push(ListItem::new(Line::from(Span::styled( + "Restart the app after adding files.", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::ITALIC), + )))); + } else { + // Display custom lessons normally + for (i, lesson) in lessons.iter().enumerate() { + all_items.push(render_lesson_item(i, &lesson.title, i == selected)); + } + } + } + _ => { + // Standard rendering for other categories (Key Training, Adaptive) + for (i, lesson) in lessons.iter().enumerate() { + all_items.push(render_lesson_item(i, &lesson.title, i == selected)); + } + } + } + + // Calculate visible slice based on scroll offset + let total_items = all_items.len(); + let visible_start = scroll_offset.min(total_items.saturating_sub(1)); + let visible_end = (visible_start + menu_area_height).min(total_items); + let visible_items: Vec = all_items + .into_iter() + .skip(visible_start) + .take(visible_end - visible_start) + .collect(); + + // Add scroll indicator to title + let scroll_indicator = if total_items > menu_area_height { + format!( + " (showing {}-{} of {})", + visible_start + 1, + visible_end, + total_items + ) + } else { + String::new() + }; + + let title = format!("Typing Lessons{}", scroll_indicator); + + let list = List::new(visible_items).block( + Block::default() + .title(title) + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), + ); + + f.render_widget(list, chunks[1]); + + // Instructions + let instructions = vec![ + Line::from(""), + Line::from(Span::styled( + "Use ↑/↓ or j/k to navigate • Press Enter/Space or 1-9 to select • ESC to go back", + Style::default().fg(Color::Gray), + )), + ]; + + let instructions_widget = Paragraph::new(instructions).alignment(Alignment::Center); + + f.render_widget(instructions_widget, chunks[2]); +} + +/// Rendu du menu de sélection de durée +pub fn render_duration_menu(f: &mut Frame, selected: usize) { + use crate::engine::SessionDuration; + + let durations = SessionDuration::all(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(10), // Duration list + Constraint::Length(3), // Instructions + ]) + .split(f.area()); + + // Header + let header = Paragraph::new("TYPER CLI - Select Session Duration") + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + f.render_widget(header, chunks[0]); + + // Duration list + let items: Vec = durations + .iter() + .enumerate() + .map(|(i, duration): (usize, &SessionDuration)| { + let style = if i == selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + let prefix = if i == selected { "▶ " } else { " " }; + let content = format!("{}{}", prefix, duration.label()); + + ListItem::new(Line::from(Span::styled(content, style))) + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .title("Duration") + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), + ); + + f.render_widget(list, chunks[1]); + + // Instructions + let instructions = vec![ + Line::from(""), + Line::from(Span::styled( + "Use ↑/↓ or j/k to navigate • Press Enter/Space to start • ESC to go back", + Style::default().fg(Color::Gray), + )), + ]; + + let instructions_widget = Paragraph::new(instructions).alignment(Alignment::Center); + + f.render_widget(instructions_widget, chunks[2]); +} + +/// Render lesson type category menu +pub fn render_lesson_type_menu( + f: &mut Frame, + categories: &[crate::content::LessonCategory], + selected: usize, + layout_variant: crate::keyboard::LayoutVariant, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(10), // Menu + Constraint::Length(3), // Instructions + ]) + .split(f.area()); + + // Header + let header = Paragraph::new("TYPER CLI - Select Lesson Type") + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + f.render_widget(header, chunks[0]); + + // Build category menu items (two-line format) + let mut items: Vec = Vec::new(); + + for (i, category) in categories.iter().enumerate() { + let is_selected = i == selected; + + // First line: number and name + let name_style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(category.color) + }; + + let prefix = if is_selected { "▶ " } else { " " }; + let name_line = format!("{}{}. {}", prefix, i + 1, category.name); + + items.push(ListItem::new(Line::from(Span::styled( + name_line, name_style, + )))); + + // Second line: description + let description_line = format!(" {}", category.description); + items.push(ListItem::new(Line::from(Span::styled( + description_line, + Style::default().fg(Color::Gray), + )))); + + // Blank line between categories (except after last) + if i < categories.len() - 1 { + items.push(ListItem::new(Line::from(""))); + } + } + + let list = List::new(items).block( + Block::default() + .title("Lesson Categories") + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), + ); + + f.render_widget(list, chunks[1]); + + // Instructions + let layout_name = match layout_variant { + crate::keyboard::LayoutVariant::Mac => "Mac", + crate::keyboard::LayoutVariant::Pc => "PC", + }; + let hint = format!( + "↑/↓ j/k navigate • Enter/1-5 select • [s] Statistics • [p] Preferences (Layout: {}) • ESC quit", + layout_name + ); + let instructions = vec![ + Line::from(""), + Line::from(Span::styled(hint, Style::default().fg(Color::Gray))), + ]; + + let instructions_widget = Paragraph::new(instructions).alignment(Alignment::Center); + + f.render_widget(instructions_widget, chunks[2]); +} diff --git a/src/ui/render/mod.rs b/src/ui/render/mod.rs new file mode 100644 index 0000000..ab87f19 --- /dev/null +++ b/src/ui/render/mod.rs @@ -0,0 +1,35 @@ +//! Terminal rendering, split by screen concern: +//! - `session`: the live typing screen (text window, cursor, per-key styling) +//! - `menu`: lesson, duration, and category menus +//! - `results`: the post-session results screen +//! - `statistics`: the statistics dashboard and its sub-blocks +//! - `analytics_screen`: analytics history/details/export and settings + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, + Frame, +}; + +use crate::content::Lesson; +use crate::data::Stats; +use crate::engine::analytics::{AdaptiveAnalytics, MasteryLevel}; +use crate::engine::TypingSession; +use crate::keyboard::AzertyLayout; +use crate::ui::keyboard::{render_keyboard, render_keyboard_compact, KeyboardConfig}; + +mod analytics_screen; +mod menu; +mod results; +mod session; +mod statistics; + +pub use analytics_screen::{ + render_analytics_details, render_analytics_export, render_analytics_history, render_settings, +}; +pub use menu::{render_duration_menu, render_lesson_type_menu, render_menu}; +pub use results::render_results; +pub use session::render; +pub use statistics::render_statistics; diff --git a/src/ui/render/results.rs b/src/ui/render/results.rs new file mode 100644 index 0000000..3bb0e9c --- /dev/null +++ b/src/ui/render/results.rs @@ -0,0 +1,80 @@ +use super::*; + +/// Rendu de l'écran de fin +pub fn render_results( + f: &mut Frame, + wpm: f64, + accuracy: f64, + duration: std::time::Duration, + error_count: usize, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(4) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(8), // Results + Constraint::Length(2), // Instructions + ]) + .split(f.area()); + + // Titre + let title = Paragraph::new("Session Complete!") + .style( + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + + f.render_widget(title, chunks[0]); + + // Résultats + let results_text = vec![ + Line::from(""), + Line::from(Span::styled( + format!("WPM: {:.1}", wpm), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + format!("Accuracy: {:.1}%", accuracy), + Style::default().fg(Color::Green), + )), + Line::from(Span::styled( + format!("Errors: {}", error_count), + Style::default().fg(Color::Red), + )), + Line::from(Span::styled( + format!( + "Time: {:02}:{:02}", + duration.as_secs() / 60, + duration.as_secs() % 60 + ), + Style::default().fg(Color::Yellow), + )), + ]; + + let results = Paragraph::new(results_text) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + + f.render_widget(results, chunks[1]); + + // Instructions + let instructions = Paragraph::new("Press ESC to return to menu • Press 'r' to restart") + .style(Style::default().fg(Color::Gray)) + .alignment(Alignment::Center); + + f.render_widget(instructions, chunks[2]); +} diff --git a/src/ui/render/session.rs b/src/ui/render/session.rs new file mode 100644 index 0000000..9df92a7 --- /dev/null +++ b/src/ui/render/session.rs @@ -0,0 +1,461 @@ +use super::*; + +/// Structure for visible text window +struct VisibleWindow { + lines: Vec, + /// Cumulative character count at start of each visible line (for index translation) + line_start_indices: Vec, +} + +/// Wrap text to fit terminal width, preserving newlines +fn wrap_text(content: &str, width: usize) -> Vec { + let mut lines = Vec::new(); + + // Split by newlines first to preserve them + for raw_line in content.split('\n') { + // Wrap each line if it's too long + let mut current_line = String::new(); + + for word in raw_line.split_whitespace() { + if current_line.is_empty() { + current_line = word.to_string(); + } else if current_line.chars().count() + 1 + word.chars().count() <= width { + // Count columns in chars, not bytes: accented French words (é, è...) + // are multibyte and would otherwise wrap too early. + current_line.push(' '); + current_line.push_str(word); + } else { + lines.push(current_line); + current_line = word.to_string(); + } + } + + // Always push the line (even if empty) to preserve blank lines + lines.push(current_line); + } + + lines +} + +/// Find which wrapped line contains a given character position +fn find_cursor_line(lines: &[String], char_pos: usize) -> (usize, usize) { + let mut char_count = 0; + + for (line_idx, line) in lines.iter().enumerate() { + let line_len = line.chars().count(); + + // Check if cursor is within this line's text + if char_pos < char_count + line_len { + return (line_idx, char_pos.saturating_sub(char_count)); + } + + // Check if cursor is on the newline at the end of this line + if char_pos == char_count + line_len && line_idx < lines.len() - 1 { + return (line_idx, line_len); + } + + // Move to next line: add line length + 1 for newline character + char_count += line_len + 1; + } + + // If not found, return last line + (lines.len().saturating_sub(1), 0) +} + +/// Extract 3-line visible window starting from cursor position +fn extract_visible_window(session: &TypingSession, width: usize) -> VisibleWindow { + let content = &session.content; + let cursor_pos = session.current_index; + + // Calculate effective width (subtract borders and padding) + let effective_width = width.saturating_sub(4); + + // Wrap text to terminal width + let lines = wrap_text(content, effective_width); + + // Find which line contains the cursor + let (cursor_line_idx, _) = find_cursor_line(&lines, cursor_pos); + + // Extract 3 lines starting from cursor line + let visible_lines: Vec = lines + .iter() + .skip(cursor_line_idx) + .take(3) + .cloned() + .collect(); + + // Compute cumulative character indices for visible lines + let mut line_start_indices = Vec::new(); + for idx in cursor_line_idx..(cursor_line_idx + visible_lines.len()) { + // Calculate chars from start of content to this line + let chars_before_line: usize = lines + .iter() + .take(idx) + .map(|l| l.chars().count() + 1) // +1 for space between words + .sum(); + line_start_indices.push(chars_before_line); + } + + VisibleWindow { + lines: visible_lines, + line_start_indices, + } +} + +/// Create styled expected text with character-level visual feedback +/// - Correctly typed characters: dark gray (dimmed) +/// - Mistyped characters: red (error indication) +/// - Next character to type: white + bold + underlined +/// - Remaining characters: white +fn create_styled_expected_text( + session: &TypingSession, + window: &VisibleWindow, +) -> Vec> { + let mut result_lines = Vec::new(); + + for (line_idx, line) in window.lines.iter().enumerate() { + let mut spans = Vec::new(); + let line_start_index = window.line_start_indices[line_idx]; + + // Render each character in the line + for (char_offset, ch) in line.chars().enumerate() { + let absolute_index = line_start_index + char_offset; + + let style = if absolute_index < session.current_index { + // Already typed - check if correct or incorrect + if absolute_index < session.inputs.len() { + let input = &session.inputs[absolute_index]; + if input.is_correct { + // Correct - dim (dark gray) + Style::default().fg(Color::DarkGray) + } else { + // Incorrect - red to show error + Style::default().fg(Color::Red) + } + } else { + // Fallback (shouldn't happen) + Style::default().fg(Color::DarkGray) + } + } else if absolute_index == session.current_index { + // Next character - highlight + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + } else { + // Remaining - normal + Style::default().fg(Color::White) + }; + + // Display spaces as dots when they have errors, for visibility + let display_char = if ch == ' ' + && absolute_index < session.current_index + && absolute_index < session.inputs.len() + && !session.inputs[absolute_index].is_correct + { + '·' // Show dot for space errors + } else { + ch + }; + + spans.push(Span::styled(display_char.to_string(), style)); + } + + // Add newline icon at the end of each line (except the very last line in content) + let newline_index = line_start_index + line.chars().count(); + if newline_index < session.content_buffer_size { + // Determine if this position represents a newline in the original content + if session.char_at(newline_index) == Some('\n') { + let style = if newline_index < session.current_index { + // Already typed - check if correct or incorrect + if newline_index < session.inputs.len() { + let input = &session.inputs[newline_index]; + if input.is_correct { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::Red) + } + } else { + Style::default().fg(Color::DarkGray) + } + } else if newline_index == session.current_index { + // Next character to type - highlight + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + } else { + // Remaining + Style::default().fg(Color::White) + }; + + spans.push(Span::styled("↵".to_string(), style)); + } + } + + result_lines.push(Line::from(spans)); + } + + result_lines +} + +/// Rendu de l'interface principale +#[allow(clippy::too_many_arguments)] +pub fn render( + f: &mut Frame, + session: &TypingSession, + wpm: f64, + accuracy: f64, + keyboard_visible: bool, + keyboard_layout: &AzertyLayout, + analytics: &Option, + keyboard_config: &KeyboardConfig, + lesson_name: &str, +) { + let terminal_height = f.area().height; + + // Dynamic constraints based on keyboard visibility and terminal size + // New layout: Header -> Stats -> Content -> Keyboard -> Spacer -> Instructions + let constraints = if keyboard_visible { + if terminal_height >= 28 { + // Full keyboard with shift indicators + vec![ + Constraint::Length(3), // Header + Constraint::Length(3), // Stats (moved after header) + Constraint::Length(14), // Content (7 + 7 lines with padding) + Constraint::Length(12), // Keyboard (follows content) + Constraint::Min(0), // Spacer (absorbs remaining space) + Constraint::Length(3), // Instructions (bottom) + ] + } else if terminal_height >= 23 { + // Full keyboard without shift line + vec![ + Constraint::Length(3), // Header + Constraint::Length(3), // Stats (moved after header) + Constraint::Length(14), // Content (7 + 7 lines with padding) + Constraint::Length(10), // Keyboard (follows content) + Constraint::Min(0), // Spacer + Constraint::Length(3), // Instructions (bottom) + ] + } else { + // Compact keyboard + vec![ + Constraint::Length(3), // Header + Constraint::Length(3), // Stats (moved after header) + Constraint::Length(14), // Content (7 + 7 lines with padding) + Constraint::Length(3), // Keyboard (compact) + Constraint::Min(0), // Spacer + Constraint::Length(3), // Instructions (bottom) + ] + } + } else { + // Layout without keyboard + vec![ + Constraint::Length(3), // Header + Constraint::Length(3), // Stats (moved after header) + Constraint::Min(8), // Content (can expand when no keyboard) + Constraint::Length(3), // Instructions (bottom) + ] + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints(constraints) + .split(f.area()); + + let mut chunk_idx = 0; + + // Header + render_header(f, chunks[chunk_idx], lesson_name); + chunk_idx += 1; + + // Stats (moved after header) + render_stats( + f, + chunks[chunk_idx], + wpm, + accuracy, + session.remaining_time(), + ); + chunk_idx += 1; + + // Content area (typing area) + render_typing_area(f, chunks[chunk_idx], session); + chunk_idx += 1; + + // Keyboard (follows content with margin) + if keyboard_visible { + let next_char = session.char_at(session.current_index); + + if terminal_height < 20 { + render_keyboard_compact(f, chunks[chunk_idx], keyboard_layout, next_char); + } else { + render_keyboard( + f, + chunks[chunk_idx], + keyboard_layout, + next_char, + analytics, + keyboard_config, + ); + } + chunk_idx += 1; // Move to spacer + chunk_idx += 1; // Move to instructions + } + // When keyboard is not visible, chunk_idx is already at instructions position + + // Instructions (bottom) + render_instructions(f, chunks[chunk_idx]); +} + +/// Rendu du header +fn render_header(f: &mut Frame, area: Rect, lesson_name: &str) { + let title_text = format!("TYPER CLI - {}", lesson_name); + let title = Paragraph::new(title_text) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + + f.render_widget(title, area); +} + +/// Create multiline colored input display +fn create_colored_input_multiline(session: &TypingSession, width: usize) -> Vec> { + let effective_width = width.saturating_sub(4); + let mut lines = Vec::new(); + let mut current_line_spans = Vec::new(); + let mut current_line_width = 0; + + for input in session.inputs.iter() { + let color = if input.is_correct { + Color::Green + } else { + Color::Red + }; + let display_char = if input.typed == ' ' { + '·' + } else if input.typed == '\n' { + '↵' + } else { + input.typed + }; + + // Check if adding this character would exceed line width + if current_line_width >= effective_width { + lines.push(Line::from(current_line_spans.clone())); + current_line_spans.clear(); + current_line_width = 0; + } + + current_line_spans.push(Span::styled( + display_char.to_string(), + Style::default().fg(color), + )); + current_line_width += 1; + } + + // Add cursor to current line + if !session.is_complete() { + current_line_spans.push(Span::styled( + "█", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::SLOW_BLINK), + )); + } + + // Push final line + if !current_line_spans.is_empty() { + lines.push(Line::from(current_line_spans)); + } + + // Return only the first 3 lines (sliding window) + lines.into_iter().take(3).collect() +} + +/// Rendu de la zone de typing (multiline with sliding window) +fn render_typing_area(f: &mut Frame, area: Rect, session: &TypingSession) { + let terminal_width = area.width as usize; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(7), // Expected text (3 lines + borders + padding) + Constraint::Length(7), // User input (3 lines + borders + padding) + ]) + .split(area); + + // Expected text - 3-line sliding window with character-level styling + let window = extract_visible_window(session, terminal_width); + let expected_lines = create_styled_expected_text(session, &window); + + let expected_text = Paragraph::new(expected_lines).block( + Block::default() + .title("Text to type") + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::new(2, 2, 1, 0)), + ); + + f.render_widget(expected_text, chunks[0]); + + // User input - multiline colored display + let user_input_lines = create_colored_input_multiline(session, terminal_width); + let input_widget = Paragraph::new(user_input_lines).block( + Block::default() + .title("Your input") + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::new(2, 2, 1, 0)), + ); + + f.render_widget(input_widget, chunks[1]); +} + +/// Rendu des statistiques +fn render_stats( + f: &mut Frame, + area: Rect, + wpm: f64, + accuracy: f64, + remaining: std::time::Duration, +) { + let stats_text = format!( + " WPM: {:.0} │ Accuracy: {:.1}% │ Time Remaining: {:02}:{:02}", + wpm, + accuracy, + remaining.as_secs() / 60, + remaining.as_secs() % 60 + ); + + let stats = Paragraph::new(stats_text) + .style(Style::default().fg(Color::Yellow)) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + + f.render_widget(stats, area); +} + +/// Rendu des instructions +fn render_instructions(f: &mut Frame, area: Rect) { + let instructions = vec![ + Line::from(""), + Line::from(Span::styled( + "ESC to quit", + Style::default().fg(Color::Gray), + )), + ]; + + let instructions_widget = Paragraph::new(instructions).alignment(Alignment::Center); + + f.render_widget(instructions_widget, area); +} diff --git a/src/ui/render/statistics.rs b/src/ui/render/statistics.rs new file mode 100644 index 0000000..976e910 --- /dev/null +++ b/src/ui/render/statistics.rs @@ -0,0 +1,491 @@ +use super::*; + +/// Render statistics and performance analytics page +pub fn render_statistics( + f: &mut Frame, + stats: &Stats, + keyboard_layout: &AzertyLayout, + keyboard_config: &KeyboardConfig, +) { + // Check if we have analytics data + if let Some(analytics) = &stats.adaptive_analytics { + render_statistics_with_data(f, stats, analytics, keyboard_layout, keyboard_config); + } else { + render_statistics_placeholder(f); + } +} + +/// Render statistics placeholder when no data exists +fn render_statistics_placeholder(f: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(4) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(10), // Content + Constraint::Length(3), // Instructions + ]) + .split(f.area()); + + // Header + let header = Paragraph::new("TYPER CLI - Statistics") + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)); + + f.render_widget(header, chunks[0]); + + // Content - placeholder message + let content_lines = vec![ + Line::from(""), + Line::from(""), + Line::from(Span::styled( + "No Statistics Available", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from("Complete your first typing session to"), + Line::from("start tracking your performance!"), + Line::from(""), + Line::from(""), + Line::from("Progress tracking includes:"), + Line::from(" • Per-key accuracy and speed"), + Line::from(" • Weakness identification"), + Line::from(" • Mastery level progression"), + Line::from(" • Common mistype patterns"), + ]; + + let content = Paragraph::new(content_lines) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)); + + f.render_widget(content, chunks[1]); + + // Instructions + let instructions = Paragraph::new("h: History d: Details e: Export ESC to return to menu") + .style(Style::default().fg(Color::Gray)) + .alignment(Alignment::Center); + + f.render_widget(instructions, chunks[2]); +} + +/// Render statistics with actual data +fn render_statistics_with_data( + f: &mut Frame, + stats: &Stats, + analytics: &AdaptiveAnalytics, + keyboard_layout: &AzertyLayout, + keyboard_config: &KeyboardConfig, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(20), // Content + Constraint::Length(3), // Instructions + ]) + .split(f.area()); + + // Header + let header = Paragraph::new("TYPER CLI - Statistics & Performance") + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + + f.render_widget(header, chunks[0]); + + // Content area - split horizontally (40% left / 60% right) + let content_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(chunks[1]); + + // Left column - split vertically for different stats sections + let left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(8), // Overall stats + Constraint::Length(20), // Mastery breakdown (4 levels x 2 lines + spacing + borders) + Constraint::Length(8), // Weaknesses (reduced by 2 lines) + Constraint::Min(10), // Common mistypes (increased) + ]) + .split(content_chunks[0]); + + // Render left column sections + render_overall_stats_block(f, stats, analytics, left_chunks[0]); + render_mastery_breakdown(f, analytics, left_chunks[1]); + render_weaknesses_list(f, analytics, left_chunks[2]); + render_common_mistypes(f, analytics, left_chunks[3]); + + // Render keyboard heatmap on the right + render_keyboard_with_heatmap( + f, + keyboard_layout, + keyboard_config, + analytics, + content_chunks[1], + ); + + // Instructions + let session_count = analytics.total_sessions; + let instructions_text = format!( + "h: History d: Details e: Export ESC to return • Analyzing {} session{}", + session_count, + if session_count == 1 { "" } else { "s" } + ); + + let instructions = Paragraph::new(instructions_text) + .style(Style::default().fg(Color::Gray)) + .alignment(Alignment::Center); + + f.render_widget(instructions, chunks[2]); +} + +/// Render overall performance statistics +fn render_overall_stats_block( + f: &mut Frame, + stats: &Stats, + analytics: &AdaptiveAnalytics, + area: Rect, +) { + let session_count = stats.session_count(); + let total_keystrokes = analytics.total_keystrokes; + let avg_wpm = stats.average_wpm(); + let avg_accuracy = stats.average_accuracy(); + + let stats_lines = vec![ + Line::from(vec![ + Span::raw("Sessions: "), + Span::styled( + format!("{}", session_count), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(vec![ + Span::raw("Total Keys: "), + Span::styled( + format!("{}", total_keystrokes), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(vec![ + Span::raw("Avg WPM: "), + Span::styled( + format!("{:.1}", avg_wpm), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(vec![ + Span::raw("Avg Accuracy: "), + Span::styled( + format!("{:.1}%", avg_accuracy), + Style::default().fg(Color::Yellow), + ), + ]), + ]; + + let block = Paragraph::new(stats_lines) + .block( + Block::default() + .title("Overall Performance") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), + ) + .alignment(Alignment::Left); + + f.render_widget(block, area); +} + +/// Group practiced keys by mastery level, each list sorted alphabetically. +/// Pure (no rendering) so it can be unit-tested. +fn keys_for_level(analytics: &AdaptiveAnalytics, level: MasteryLevel) -> Vec { + let mut keys: Vec = analytics + .key_stats + .iter() + .filter(|(_, stats)| stats.mastery_level == level) + .map(|(key, _)| *key) + .collect(); + keys.sort(); + keys +} + +/// Render mastery level breakdown +fn render_mastery_breakdown(f: &mut Frame, analytics: &AdaptiveAnalytics, area: Rect) { + let mastered_keys = keys_for_level(analytics, MasteryLevel::Mastered); + let proficient_keys = keys_for_level(analytics, MasteryLevel::Proficient); + let learning_keys = keys_for_level(analytics, MasteryLevel::Learning); + let beginner_keys = keys_for_level(analytics, MasteryLevel::Beginner); + + // Helper function to format key list + let format_key_list = |keys: &[char]| -> String { + if keys.is_empty() { + return String::from(" (none)"); + } + let display_keys: Vec = keys.iter().map(|k| k.to_string()).collect(); + format!(" {}", display_keys.join(" ")) + }; + + let mastery_lines = vec![ + Line::from(vec![ + Span::styled("■ ", Style::default().fg(Color::Green)), + Span::styled("Mastered: ", Style::default().fg(Color::Green)), + Span::styled( + format!("{} keys", mastered_keys.len()), + Style::default().fg(Color::Green), + ), + ]), + Line::from(format_key_list(&mastered_keys)), + Line::from(""), + Line::from(vec![ + Span::styled("■ ", Style::default().fg(Color::Yellow)), + Span::styled("Proficient: ", Style::default().fg(Color::Yellow)), + Span::styled( + format!("{} keys", proficient_keys.len()), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(format_key_list(&proficient_keys)), + Line::from(""), + Line::from(vec![ + Span::styled("■ ", Style::default().fg(Color::LightRed)), + Span::styled("Learning: ", Style::default().fg(Color::LightRed)), + Span::styled( + format!("{} keys", learning_keys.len()), + Style::default().fg(Color::LightRed), + ), + ]), + Line::from(format_key_list(&learning_keys)), + Line::from(""), + Line::from(vec![ + Span::styled("■ ", Style::default().fg(Color::Blue)), + Span::styled("Beginner: ", Style::default().fg(Color::Blue)), + Span::styled( + format!("{} keys", beginner_keys.len()), + Style::default().fg(Color::Blue), + ), + ]), + Line::from(format_key_list(&beginner_keys)), + ]; + + let block = Paragraph::new(mastery_lines) + .block( + Block::default() + .title("Mastery Levels") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), + ) + .alignment(Alignment::Left); + + f.render_widget(block, area); +} + +/// Render top weaknesses list +fn render_weaknesses_list(f: &mut Frame, analytics: &AdaptiveAnalytics, area: Rect) { + let mut weak_keys: Vec<_> = analytics + .key_stats + .iter() + .filter(|(_, stats)| stats.accuracy() < 80.0 && stats.total_attempts >= 5) + .collect(); + + weak_keys.sort_by(|a, b| { + a.1.accuracy() + .partial_cmp(&b.1.accuracy()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut weakness_lines = vec![]; + + if weak_keys.is_empty() { + weakness_lines.push(Line::from(Span::styled( + "No weaknesses - Great job!", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ))); + } else { + for (i, (key, stats)) in weak_keys.iter().take(10).enumerate() { + let accuracy = stats.accuracy(); + let color = if accuracy < 50.0 { + Color::Red + } else if accuracy < 70.0 { + Color::LightRed + } else { + Color::Yellow + }; + + weakness_lines.push(Line::from(vec![ + Span::raw(format!("#{} ", i + 1)), + Span::styled( + format!("'{}' - {:.0}% acc", key, accuracy), + Style::default().fg(color), + ), + Span::raw(format!(" ({} errors)", stats.error_count)), + if i == 0 { + Span::styled( + " ← WEAKEST", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ) + } else { + Span::raw("") + }, + ])); + } + } + + let block = Paragraph::new(weakness_lines) + .block( + Block::default() + .title("Weakest Keys (Lower = More Practice)") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), + ) + .alignment(Alignment::Left); + + f.render_widget(block, area); +} + +/// Render common mistype patterns +fn render_common_mistypes(f: &mut Frame, analytics: &AdaptiveAnalytics, area: Rect) { + let mut all_mistypes = Vec::new(); + for (expected, key_stats) in &analytics.key_stats { + for (typed, count) in &key_stats.mistype_map { + all_mistypes.push((expected, typed, count)); + } + } + + all_mistypes.sort_by(|a, b| b.2.cmp(a.2)); + + let mut mistype_lines = vec![]; + + if all_mistypes.is_empty() || all_mistypes.len() < 5 { + mistype_lines.push(Line::from(Span::styled( + "Insufficient data", + Style::default().fg(Color::Gray), + ))); + } else { + // Split into two columns: items 1-5 on left, 6-10 on right + let top_10: Vec<_> = all_mistypes.iter().take(10).collect(); + let max_rows = top_10.len().div_ceil(2); + + for i in 0..max_rows { + let left_item = top_10.get(i); + let right_item = top_10.get(i + max_rows); + + let mut spans = Vec::new(); + + // Left column + if let Some((expected, typed, count)) = left_item { + spans.push(Span::raw(format!( + "'{}' → '{}': {} times", + expected, typed, count + ))); + } + + // Spacing between columns + if right_item.is_some() { + spans.push(Span::raw(" ")); + } + + // Right column + if let Some((expected, typed, count)) = right_item { + spans.push(Span::raw(format!( + "'{}' → '{}': {} times", + expected, typed, count + ))); + } + + mistype_lines.push(Line::from(spans)); + } + } + + let block = Paragraph::new(mistype_lines) + .block( + Block::default() + .title("Common Mistypes") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .padding(ratatui::widgets::Padding::new(1, 1, 1, 0)), + ) + .alignment(Alignment::Left); + + f.render_widget(block, area); +} + +/// Render keyboard for statistics page +fn render_keyboard_with_heatmap( + f: &mut Frame, + keyboard_layout: &AzertyLayout, + keyboard_config: &KeyboardConfig, + analytics: &AdaptiveAnalytics, + area: Rect, +) { + // Create a custom config for statistics context + let mut stats_config = keyboard_config.clone(); + stats_config.show_heatmap = true; // Always show heatmap in stats + stats_config.show_finger_colors = false; // No finger colors in stats (heatmap takes priority) + stats_config.show_footer_shortcuts = false; // Don't show footer (we have our own instructions) + + // render_keyboard signature: (f, area, layout, next_char, analytics, config) + render_keyboard( + f, + area, + keyboard_layout, + None, // No next char highlighting + &Some(analytics.clone()), + &stats_config, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::analytics::KeyStats; + + fn key(c: char, level: MasteryLevel) -> KeyStats { + let mut k = KeyStats::new(c); + k.mastery_level = level; + k + } + + #[test] + fn keys_for_level_groups_and_sorts() { + let mut analytics = AdaptiveAnalytics::default(); + analytics + .key_stats + .insert('f', key('f', MasteryLevel::Mastered)); + analytics + .key_stats + .insert('a', key('a', MasteryLevel::Mastered)); + analytics + .key_stats + .insert('j', key('j', MasteryLevel::Learning)); + + assert_eq!( + keys_for_level(&analytics, MasteryLevel::Mastered), + vec!['a', 'f'] // sorted + ); + assert_eq!( + keys_for_level(&analytics, MasteryLevel::Learning), + vec!['j'] + ); + assert!(keys_for_level(&analytics, MasteryLevel::Beginner).is_empty()); + } +}