Skip to content

Commit 2263e2c

Browse files
jrollinclaude
andcommitted
feat: add Enter key support and random newlines in practice content
Add Enter key input handling with visual `↵` icon display and implement random newlines in practice generators for more engaging and realistic typing practice. Features: - Enter key support with `\n` character validation - Visual `↵` icon for newlines in expected text and typed input - Random separator logic: 25% newline, 75% space in practice drills - Preserved newline handling in text wrapping and cursor positioning Changes: - Input handler: Add KeyCode::Enter case (app.rs) - Text rendering: Preserve newlines in wrap_text(), display `↵` icon (render.rs) - Content generators: Add random newline separators (bigram, trigram, finger, key pair) - Tests: Add Enter key validation tests, update deterministic tests for randomization Documentation: - Add special character visualization section to TUI design doc - Update bigram generator design with random separator example - Note Enter/newline support in CLAUDE.md constraints Quality: - All 148 tests passing - Clippy warnings fixed (len_zero, manual_range_contains) - Code formatted with cargo fmt 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent c9ae6de commit 2263e2c

File tree

13 files changed

+180
-48
lines changed

13 files changed

+180
-48
lines changed

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ src/
155155
- AZERTY keyboard only (Phase 1-2)
156156
- French language only (Phase 1-2)
157157
- Backspace support enabled (Phase 1+)
158+
- Enter/newline support with `` icon visualization
159+
- Random newlines in practice content (25% probability)
158160
- No sound effects (all phases)
159161
- Terminal-only (no GUI)
160162

docs/features/bigram-training/design.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,14 @@ impl BigramGenerator {
135135
```rust
136136
fn generate_drill_mode(&self, bigrams: Vec<&Bigram>, length: usize) -> String {
137137
let mut result = String::new();
138+
let mut rng = rand::thread_rng();
138139
let mut idx = 0;
139140

140141
while result.len() < length {
141142
if !result.is_empty() {
142-
result.push(' ');
143+
// Random separator: 25% newline, 75% space
144+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
145+
result.push(separator);
143146
}
144147

145148
let bigram = &bigrams[idx % bigrams.len()];
@@ -153,7 +156,7 @@ fn generate_drill_mode(&self, bigrams: Vec<&Bigram>, length: usize) -> String {
153156
result.chars().take(length).collect()
154157
}
155158

156-
// Output: "qu qu qu ou ou ou en en en qu qu qu..."
159+
// Output: "qu qu qu ou ou ou\nen en en qu qu qu..." (random newlines)
157160
```
158161

159162
**2. Word Mode (Level 2)**: Bigrams in word context

docs/features/tui-interface/design.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,30 @@ fn render_cursor() -> Span {
144144
- Doesn't require special terminal support
145145
- Works consistently across terminals
146146

147+
### Special Character Visualization
148+
149+
**Non-printable character icons:**
150+
```rust
151+
fn display_char(ch: char) -> char {
152+
match ch {
153+
' ' => '·', // U+00B7 Middle Dot for spaces
154+
'\n' => '↵', // U+21B5 Downwards Arrow for newlines
155+
c => c,
156+
}
157+
}
158+
```
159+
160+
**Design rationale:**
161+
- **Space (·)**: Makes whitespace visible without being distracting
162+
- **Newline (↵)**: Clearly indicates line breaks in multi-line content
163+
- Icons shown in both expected text and typed input for consistency
164+
- Preserves color coding (green/red/gray) to maintain feedback
165+
166+
**Implementation notes:**
167+
- Icons are display-only; actual characters stored remain unchanged
168+
- Enter key input is validated against '\n' character
169+
- Newline support enables practice with code snippets and structured content
170+
147171
## Real-time Updates
148172

149173
### Render Loop

examples/verify_adaptive.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ fn main() {
6161

6262
if accuracy < 80.0 && total >= 10.0 {
6363
weak_keys.push((key, accuracy));
64-
} else if accuracy >= 80.0 && accuracy < 95.0 {
64+
} else if (80.0..95.0).contains(&accuracy) {
6565
moderate_keys.push((key, accuracy));
6666
} else if accuracy >= 95.0 {
6767
strong_keys.push((key, accuracy));

src/app.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,11 @@ impl App {
496496
session.add_input(c);
497497
}
498498
}
499+
KeyCode::Enter => {
500+
if let Some(session) = &mut self.session {
501+
session.add_input('\n');
502+
}
503+
}
499504
KeyCode::Backspace => {
500505
if let Some(session) = &mut self.session {
501506
session.remove_last_input();

src/content/bigram_generator.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// Content generator for bigram training lessons
22
use super::bigram::{code_bigrams, english_bigrams, french_bigrams, Bigram, BigramType, Language};
3+
use rand::Rng;
34

45
pub struct BigramGenerator {
56
bigrams: Vec<Bigram>,
@@ -52,11 +53,13 @@ impl BigramGenerator {
5253
/// Example: "qu qu qu ou ou ou en en en"
5354
fn generate_drill_mode(&self, bigrams: &[&Bigram], length: usize) -> String {
5455
let mut result = String::new();
56+
let mut rng = rand::thread_rng();
5557
let mut idx = 0;
5658

5759
while result.len() < length {
5860
if !result.is_empty() {
59-
result.push(' ');
61+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
62+
result.push(separator);
6063
}
6164

6265
let bigram = bigrams[idx % bigrams.len()];
@@ -76,11 +79,13 @@ impl BigramGenerator {
7679
/// Example: "que qui quoi pour vous nous"
7780
fn generate_word_mode(&self, bigrams: &[&Bigram], length: usize) -> String {
7881
let mut result = String::new();
82+
let mut rng = rand::thread_rng();
7983
let mut bigram_idx = 0;
8084

8185
while result.len() < length {
8286
if !result.is_empty() {
83-
result.push(' ');
87+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
88+
result.push(separator);
8489
}
8590

8691
let bigram = bigrams[bigram_idx % bigrams.len()];
@@ -100,11 +105,13 @@ impl BigramGenerator {
100105
/// Combines examples into natural-looking phrases
101106
fn generate_mixed_mode(&self, bigrams: &[&Bigram], length: usize) -> String {
102107
let mut result = String::new();
108+
let mut rng = rand::thread_rng();
103109
let mut word_count = 0;
104110

105111
while result.len() < length {
106112
if word_count > 0 {
107-
result.push(' ');
113+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
114+
result.push(separator);
108115
}
109116

110117
// Pick a bigram
@@ -206,13 +213,16 @@ mod tests {
206213
}
207214

208215
#[test]
209-
fn test_deterministic_generation() {
216+
fn test_random_newlines_in_generation() {
210217
let gen = BigramGenerator::new(BigramType::Natural, Some(Language::French));
211218

212-
let content1 = gen.generate(2, 40);
213-
let content2 = gen.generate(2, 40);
219+
let content = gen.generate(2, 100);
214220

215-
// Same level and length should produce same content
216-
assert_eq!(content1, content2);
221+
// Content should contain both spaces and newlines (random mix)
222+
assert!(content.contains(' '));
223+
// Content should have expected words from top bigrams
224+
assert!(content.contains("les") || content.contains("de") || content.contains("en"));
225+
// Content length should respect constraint
226+
assert!(content.chars().count() <= 100);
217227
}
218228
}

src/content/code_generator.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ impl CodeSymbolGenerator {
3636

3737
while result.len() < length {
3838
if !result.is_empty() {
39-
result.push(' ');
39+
result.push('\n');
4040
}
4141

4242
let snippet = &filtered_snippets[idx % filtered_snippets.len()];

src/content/finger_generator.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::content::lesson::FingerPairType;
22
use crate::keyboard::azerty::{AzertyLayout, Finger, RowType};
33
use rand::seq::SliceRandom;
4+
use rand::Rng;
45

56
/// Extract keys assigned to a specific finger pair at a given difficulty level
67
pub fn get_finger_pair_keys(
@@ -82,6 +83,7 @@ pub fn generate_finger_drills(keys: &[char], length: usize, with_shift: bool) ->
8283
/// Generate drills with only base characters (3-phase pattern)
8384
fn generate_base_drills(keys: &[char], length: usize) -> String {
8485
let mut result = String::new();
86+
let mut rng = rand::thread_rng();
8587
let mut patterns = Vec::new();
8688

8789
// Phase 1: Single key repetitions (warm-up)
@@ -117,8 +119,9 @@ fn generate_base_drills(keys: &[char], length: usize) -> String {
117119
let mut idx = 0;
118120
while result.len() < length {
119121
if !result.is_empty() {
120-
result.push(' ');
121-
// Check if adding space would exceed length
122+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
123+
result.push(separator);
124+
// Check if adding separator would exceed length
122125
if result.len() >= length {
123126
break;
124127
}
@@ -206,8 +209,9 @@ fn generate_shift_drills(keys: &[char], length: usize) -> String {
206209
let mut idx = 0;
207210
while result.len() < length {
208211
if !result.is_empty() {
209-
result.push(' ');
210-
// Check if adding space would exceed length
212+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
213+
result.push(separator);
214+
// Check if adding separator would exceed length
211215
if result.len() >= length {
212216
break;
213217
}

src/content/generator.rs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ fn generate_two_key_drills(keys: &[char], length: usize) -> String {
9595
}
9696

9797
let mut result = String::new();
98+
let mut rng = rand::thread_rng();
9899
let pattern = [
99100
format!("{}{}", keys[0], keys[0]),
100101
format!("{}{}", keys[1], keys[1]),
@@ -103,7 +104,8 @@ fn generate_two_key_drills(keys: &[char], length: usize) -> String {
103104
let mut idx = 0;
104105
while result.len() < length {
105106
if !result.is_empty() {
106-
result.push(' ');
107+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
108+
result.push(separator);
107109
}
108110
result.push_str(&pattern[idx % pattern.len()]);
109111
idx += 1;
@@ -122,6 +124,7 @@ fn generate_progressive_drills(keys: &[char], length: usize) -> String {
122124
}
123125

124126
let mut result = String::new();
127+
let mut rng = rand::thread_rng();
125128
let mut patterns = Vec::new();
126129

127130
// Phase 1: Répétitions de chaque touche
@@ -151,7 +154,8 @@ fn generate_progressive_drills(keys: &[char], length: usize) -> String {
151154
let mut idx = 0;
152155
while result.len() < length {
153156
if !result.is_empty() {
154-
result.push(' ');
157+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
158+
result.push(separator);
155159
}
156160
result.push_str(&patterns[idx % patterns.len()]);
157161
idx += 1;
@@ -172,11 +176,13 @@ fn generate_words(_keys: &[char], length: usize) -> String {
172176
];
173177

174178
let mut result = String::new();
179+
let mut rng = rand::thread_rng();
175180
let mut idx = 0;
176181

177182
while result.len() < length {
178183
if !result.is_empty() {
179-
result.push(' ');
184+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
185+
result.push(separator);
180186
}
181187
result.push_str(words[idx % words.len()]);
182188
idx += 1;
@@ -193,6 +199,7 @@ fn generate_key_pair_drills(keys: &[char], length: usize) -> String {
193199
}
194200

195201
let mut result = String::new();
202+
let mut rng = rand::thread_rng();
196203
let mut patterns = Vec::new();
197204

198205
// Phase 1: Single key repetitions (warm-up)
@@ -228,7 +235,8 @@ fn generate_key_pair_drills(keys: &[char], length: usize) -> String {
228235
let mut idx = 0;
229236
while result.len() < length {
230237
if !result.is_empty() {
231-
result.push(' ');
238+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
239+
result.push(separator);
232240
}
233241
result.push_str(&patterns[idx % patterns.len()]);
234242
idx += 1;
@@ -331,7 +339,8 @@ fn generate_shift_variant_drills(group: &super::lesson::KeyPairGroupDef, length:
331339
let mut idx = 0;
332340
while result.len() < length {
333341
if !result.is_empty() {
334-
result.push(' ');
342+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
343+
result.push(separator);
335344
}
336345
result.push_str(&patterns[idx % patterns.len()]);
337346
idx += 1;
@@ -346,9 +355,11 @@ mod tests {
346355

347356
#[test]
348357
fn test_generate_two_key_drills() {
349-
let result = generate_two_key_drills(&['f', 'j'], 15);
350-
assert!(result.starts_with("ff jj ff jj"));
351-
assert!(result.len() <= 15);
358+
let result = generate_two_key_drills(&['f', 'j'], 30);
359+
// Should contain both patterns (may have spaces or newlines between)
360+
assert!(result.contains("ff"));
361+
assert!(result.contains("jj"));
362+
assert!(result.len() <= 30);
352363
}
353364

354365
#[test]

src/content/trigram_generator.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/// Content generator for trigram training lessons
22
use super::bigram::Language;
33
use super::trigram::{english_trigrams, french_trigrams, Trigram};
4+
use rand::Rng;
45

56
pub struct TrigramGenerator {
67
trigrams: Vec<Trigram>,
@@ -49,11 +50,13 @@ impl TrigramGenerator {
4950
/// Example: "the the the and and and"
5051
fn generate_drill_mode(&self, trigrams: &[&Trigram], length: usize) -> String {
5152
let mut result = String::new();
53+
let mut rng = rand::thread_rng();
5254
let mut idx = 0;
5355

5456
while result.len() < length {
5557
if !result.is_empty() {
56-
result.push(' ');
58+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
59+
result.push(separator);
5760
}
5861

5962
let trigram = trigrams[idx % trigrams.len()];
@@ -73,11 +76,13 @@ impl TrigramGenerator {
7376
/// Example: "the them then and hand stand"
7477
fn generate_word_mode(&self, trigrams: &[&Trigram], length: usize) -> String {
7578
let mut result = String::new();
79+
let mut rng = rand::thread_rng();
7680
let mut trigram_idx = 0;
7781

7882
while result.len() < length {
7983
if !result.is_empty() {
80-
result.push(' ');
84+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
85+
result.push(separator);
8186
}
8287

8388
let trigram = trigrams[trigram_idx % trigrams.len()];
@@ -97,11 +102,13 @@ impl TrigramGenerator {
97102
/// Combines examples into natural-looking phrases
98103
fn generate_mixed_mode(&self, trigrams: &[&Trigram], length: usize) -> String {
99104
let mut result = String::new();
105+
let mut rng = rand::thread_rng();
100106
let mut word_count = 0;
101107

102108
while result.len() < length {
103109
if word_count > 0 {
104-
result.push(' ');
110+
let separator = if rng.gen_bool(0.25) { '\n' } else { ' ' };
111+
result.push(separator);
105112
}
106113

107114
// Pick a trigram
@@ -195,14 +202,17 @@ mod tests {
195202
}
196203

197204
#[test]
198-
fn test_deterministic_generation() {
205+
fn test_random_newlines_in_generation() {
199206
let gen = TrigramGenerator::new(Language::French);
200207

201-
let content1 = gen.generate(2, 40);
202-
let content2 = gen.generate(2, 40);
208+
let content = gen.generate(2, 100);
203209

204-
// Same level and length should produce same content
205-
assert_eq!(content1, content2);
210+
// Content should contain both spaces and newlines (random mix)
211+
assert!(content.contains(' '));
212+
// Content should have expected words from top trigrams
213+
assert!(content.contains("les") || content.contains("des") || content.contains("ment"));
214+
// Content length should respect constraint
215+
assert!(content.chars().count() <= 100);
206216
}
207217

208218
#[test]

0 commit comments

Comments
 (0)