Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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*.*.*`:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
57 changes: 46 additions & 11 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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<usize> {
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 {
Expand All @@ -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);
}
}
6 changes: 3 additions & 3 deletions src/content/adaptive_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ');
}
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down
106 changes: 22 additions & 84 deletions src/content/bigram_generator.rs
Original file line number Diff line number Diff line change
@@ -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<Bigram>,
Expand Down Expand Up @@ -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(),
}
}
Expand All @@ -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)]
Expand All @@ -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"));
Expand Down Expand Up @@ -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));
Expand Down
8 changes: 4 additions & 4 deletions src/content/code_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand Down
Loading
Loading