Skip to content

Commit a8aaf09

Browse files
jrollinclaude
andcommitted
feat: Improve lesson menu UX with scrolling and reversed flow
- Add scrolling support for long lesson lists (52+ items) - Reverse menu flow: select lesson first, then duration - Display current lesson name in typing session header - Update menu instructions to reflect new flow - Add scroll position indicator when list exceeds viewport πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
1 parent 8f885f2 commit a8aaf09

File tree

2 files changed

+98
-53
lines changed

2 files changed

+98
-53
lines changed

β€Žsrc/app.rsβ€Ž

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub struct App {
3030
stats: Stats,
3131
selected_lesson: usize,
3232
lessons: Vec<Lesson>,
33+
lesson_scroll_offset: usize,
3334
selected_duration: usize,
3435
selected_duration_value: crate::engine::SessionDuration,
3536
keyboard_visible: bool,
@@ -147,11 +148,12 @@ impl App {
147148

148149
Ok(Self {
149150
session: None,
150-
state: AppState::DurationMenu,
151+
state: AppState::LessonMenu, // Start with lesson selection
151152
storage,
152153
stats,
153154
selected_lesson: 0,
154155
lessons,
156+
lesson_scroll_offset: 0,
155157
selected_duration: 2, // Default to 5 minutes (index 2)
156158
selected_duration_value: crate::engine::SessionDuration::FiveMinutes,
157159
keyboard_visible: true, // Default visible
@@ -212,12 +214,12 @@ impl App {
212214
loop {
213215
// Render
214216
terminal.draw(|f| match self.state {
217+
AppState::LessonMenu => {
218+
ui::render_menu(f, &self.lessons, self.selected_lesson, self.lesson_scroll_offset);
219+
}
215220
AppState::DurationMenu => {
216221
ui::render_duration_menu(f, self.selected_duration);
217222
}
218-
AppState::LessonMenu => {
219-
ui::render_menu(f, &self.lessons, self.selected_lesson);
220-
}
221223
AppState::Running | AppState::Completed => {
222224
if let Some(session) = &self.session {
223225
let result = calculate_results(session);
@@ -231,6 +233,7 @@ impl App {
231233
result.error_count,
232234
);
233235
} else {
236+
let lesson_name = &self.lessons[self.selected_lesson].title;
234237
ui::render(
235238
f,
236239
session,
@@ -240,6 +243,7 @@ impl App {
240243
&self.keyboard_layout,
241244
&self.stats.adaptive_analytics,
242245
&self.keyboard_config,
246+
lesson_name,
243247
);
244248
}
245249
}
@@ -292,63 +296,75 @@ impl App {
292296
}
293297

294298
match self.state {
295-
AppState::DurationMenu => match key.code {
299+
AppState::LessonMenu => match key.code {
296300
KeyCode::Esc | KeyCode::Char('q') => {
301+
// Quit from first menu
297302
self.state = AppState::Quit;
298303
}
299304
KeyCode::Up | KeyCode::Char('k') => {
300-
if self.selected_duration > 0 {
301-
self.selected_duration -= 1;
305+
if self.selected_lesson > 0 {
306+
self.selected_lesson -= 1;
307+
// Scroll up if selection goes above viewport
308+
if self.selected_lesson < self.lesson_scroll_offset {
309+
self.lesson_scroll_offset = self.selected_lesson;
310+
}
302311
}
303312
}
304313
KeyCode::Down | KeyCode::Char('j') => {
305-
let max_idx = crate::engine::SessionDuration::all().len() - 1;
306-
if self.selected_duration < max_idx {
307-
self.selected_duration += 1;
314+
if self.selected_lesson < self.lessons.len() - 1 {
315+
self.selected_lesson += 1;
316+
// Scroll down if selection goes below viewport (using conservative estimate of 20)
317+
let viewport_height = 20;
318+
if self.selected_lesson >= self.lesson_scroll_offset + viewport_height {
319+
self.lesson_scroll_offset = self.selected_lesson - viewport_height + 1;
320+
}
308321
}
309322
}
310323
KeyCode::Enter | KeyCode::Char(' ') => {
311-
// Save selected duration and move to lesson menu
312-
self.selected_duration_value =
313-
crate::engine::SessionDuration::all()[self.selected_duration];
314-
self.state = AppState::LessonMenu;
324+
// Go to duration menu after lesson selected
325+
self.state = AppState::DurationMenu;
326+
}
327+
KeyCode::Char(c) if c.is_ascii_digit() => {
328+
// Allow direct selection with numbers
329+
if let Some(digit) = c.to_digit(10) {
330+
let index = (digit as usize).saturating_sub(1);
331+
if index < self.lessons.len() {
332+
self.selected_lesson = index;
333+
// Go to duration menu after lesson selected
334+
self.state = AppState::DurationMenu;
335+
}
336+
}
315337
}
316338
_ => {}
317339
},
318-
AppState::LessonMenu => match key.code {
340+
AppState::DurationMenu => match key.code {
319341
KeyCode::Esc | KeyCode::Char('q') => {
320-
// Go back to duration menu
321-
self.state = AppState::DurationMenu;
342+
// Go back to lesson menu
343+
self.state = AppState::LessonMenu;
322344
}
323345
KeyCode::Up | KeyCode::Char('k') => {
324-
if self.selected_lesson > 0 {
325-
self.selected_lesson -= 1;
346+
if self.selected_duration > 0 {
347+
self.selected_duration -= 1;
326348
}
327349
}
328350
KeyCode::Down | KeyCode::Char('j') => {
329-
if self.selected_lesson < self.lessons.len() - 1 {
330-
self.selected_lesson += 1;
351+
let max_idx = crate::engine::SessionDuration::all().len() - 1;
352+
if self.selected_duration < max_idx {
353+
self.selected_duration += 1;
331354
}
332355
}
333356
KeyCode::Enter | KeyCode::Char(' ') => {
357+
// Save selected duration and start lesson
358+
self.selected_duration_value =
359+
crate::engine::SessionDuration::all()[self.selected_duration];
334360
self.start_lesson(self.selected_lesson);
335361
}
336-
KeyCode::Char(c) if c.is_ascii_digit() => {
337-
// Allow direct selection with numbers 1-6
338-
if let Some(digit) = c.to_digit(10) {
339-
let index = (digit as usize).saturating_sub(1);
340-
if index < self.lessons.len() {
341-
self.selected_lesson = index;
342-
self.start_lesson(index);
343-
}
344-
}
345-
}
346362
_ => {}
347363
},
348364
AppState::Running => match key.code {
349365
KeyCode::Esc => {
350-
// Return to duration menu (discard session)
351-
self.state = AppState::DurationMenu;
366+
// Return to lesson menu (discard session)
367+
self.state = AppState::LessonMenu;
352368
self.session = None;
353369
}
354370
KeyCode::Tab => {
@@ -383,13 +399,13 @@ impl App {
383399
AppState::Completed => {
384400
match key.code {
385401
KeyCode::Char('q') | KeyCode::Esc => {
386-
// Return to duration menu
387-
self.state = AppState::DurationMenu;
402+
// Return to lesson menu
403+
self.state = AppState::LessonMenu;
388404
self.session = None;
389405
}
390406
KeyCode::Char('r') => {
391-
// Restart same lesson with same duration
392-
self.start_lesson(self.selected_lesson);
407+
// Re-select duration for restart
408+
self.state = AppState::DurationMenu;
393409
}
394410
_ => {}
395411
}

β€Žsrc/ui/render.rsβ€Ž

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ pub fn render(
174174
keyboard_layout: &AzertyLayout,
175175
analytics: &Option<AdaptiveAnalytics>,
176176
keyboard_config: &KeyboardConfig,
177+
lesson_name: &str,
177178
) {
178179
let terminal_height = f.area().height;
179180

@@ -230,7 +231,7 @@ pub fn render(
230231
let mut chunk_idx = 0;
231232

232233
// Header
233-
render_header(f, chunks[chunk_idx]);
234+
render_header(f, chunks[chunk_idx], lesson_name);
234235
chunk_idx += 1;
235236

236237
// Stats (moved after header)
@@ -273,8 +274,9 @@ pub fn render(
273274
}
274275

275276
/// Rendu du header
276-
fn render_header(f: &mut Frame, area: Rect) {
277-
let title = Paragraph::new("TYPER CLI - Home Row Practice")
277+
fn render_header(f: &mut Frame, area: Rect, lesson_name: &str) {
278+
let title_text = format!("TYPER CLI - {}", lesson_name);
279+
let title = Paragraph::new(title_text)
278280
.style(
279281
Style::default()
280282
.fg(Color::Cyan)
@@ -407,7 +409,12 @@ fn render_instructions(f: &mut Frame, area: Rect) {
407409
}
408410

409411
/// Rendu du menu de sΓ©lection de leΓ§on
410-
pub fn render_menu(f: &mut Frame, lessons: &[Lesson], selected: usize) {
412+
pub fn render_menu(
413+
f: &mut Frame,
414+
lessons: &[Lesson],
415+
selected: usize,
416+
scroll_offset: usize,
417+
) {
411418
let chunks = Layout::default()
412419
.direction(Direction::Vertical)
413420
.margin(2)
@@ -429,11 +436,14 @@ pub fn render_menu(f: &mut Frame, lessons: &[Lesson], selected: usize) {
429436
.block(Block::default().borders(Borders::ALL));
430437
f.render_widget(header, chunks[0]);
431438

432-
// Menu items with category separators
433-
let mut items: Vec<ListItem> = Vec::new();
439+
// Calculate visible area height (minus borders and padding)
440+
let menu_area_height = chunks[1].height.saturating_sub(2) as usize;
441+
442+
// Build complete items list with category separators
443+
let mut all_items: Vec<ListItem> = Vec::new();
434444

435445
// PRIMARY section header
436-
items.push(ListItem::new(Line::from(Span::styled(
446+
all_items.push(ListItem::new(Line::from(Span::styled(
437447
"━━━ PRIMARY - Key Training ━━━",
438448
Style::default()
439449
.fg(Color::Cyan)
@@ -444,8 +454,8 @@ pub fn render_menu(f: &mut Frame, lessons: &[Lesson], selected: usize) {
444454
for (i, lesson) in lessons.iter().enumerate() {
445455
// Add SECONDARY separator before lesson 25 (0-indexed: 24)
446456
if i == 25 {
447-
items.push(ListItem::new(Line::from("")));
448-
items.push(ListItem::new(Line::from(Span::styled(
457+
all_items.push(ListItem::new(Line::from("")));
458+
all_items.push(ListItem::new(Line::from(Span::styled(
449459
"━━━ SECONDARY - Programming & Languages ━━━",
450460
Style::default()
451461
.fg(Color::Cyan)
@@ -460,8 +470,8 @@ pub fn render_menu(f: &mut Frame, lessons: &[Lesson], selected: usize) {
460470
crate::content::lesson::LessonType::Adaptive
461471
)
462472
{
463-
items.push(ListItem::new(Line::from("")));
464-
items.push(ListItem::new(Line::from(Span::styled(
473+
all_items.push(ListItem::new(Line::from("")));
474+
all_items.push(ListItem::new(Line::from(Span::styled(
465475
"━━━ ADAPTIVE ━━━",
466476
Style::default()
467477
.fg(Color::Cyan)
@@ -480,12 +490,31 @@ pub fn render_menu(f: &mut Frame, lessons: &[Lesson], selected: usize) {
480490
let prefix = if i == selected { "β–Ά " } else { " " };
481491
let content = format!("{}{}. {}", prefix, i + 1, lesson.title);
482492

483-
items.push(ListItem::new(Line::from(Span::styled(content, style))));
493+
all_items.push(ListItem::new(Line::from(Span::styled(content, style))));
484494
}
485495

486-
let list = List::new(items).block(
496+
// Calculate visible slice based on scroll offset
497+
let total_items = all_items.len();
498+
let visible_start = scroll_offset.min(total_items.saturating_sub(1));
499+
let visible_end = (visible_start + menu_area_height).min(total_items);
500+
let visible_items: Vec<ListItem> = all_items
501+
.into_iter()
502+
.skip(visible_start)
503+
.take(visible_end - visible_start)
504+
.collect();
505+
506+
// Add scroll indicator to title
507+
let scroll_indicator = if total_items > menu_area_height {
508+
format!(" (showing {}-{} of {})", visible_start + 1, visible_end, total_items)
509+
} else {
510+
String::new()
511+
};
512+
513+
let title = format!("Typing Lessons{}", scroll_indicator);
514+
515+
let list = List::new(visible_items).block(
487516
Block::default()
488-
.title("Typing Lessons")
517+
.title(title)
489518
.borders(Borders::ALL),
490519
);
491520

@@ -495,7 +524,7 @@ pub fn render_menu(f: &mut Frame, lessons: &[Lesson], selected: usize) {
495524
let instructions = vec![
496525
Line::from(""),
497526
Line::from(Span::styled(
498-
"Use ↑/↓ or j/k to navigate β€’ Press Enter/Space or 1-6 to start β€’ ESC to quit",
527+
"Use ↑/↓ or j/k to navigate β€’ Press Enter/Space or 1-6 to select β€’ ESC to quit",
499528
Style::default().fg(Color::Gray),
500529
)),
501530
];
@@ -560,7 +589,7 @@ pub fn render_duration_menu(f: &mut Frame, selected: usize) {
560589
let instructions = vec![
561590
Line::from(""),
562591
Line::from(Span::styled(
563-
"Use ↑/↓ or j/k to navigate β€’ Press Enter/Space to continue β€’ ESC to quit",
592+
"Use ↑/↓ or j/k to navigate β€’ Press Enter/Space to start β€’ ESC to go back",
564593
Style::default().fg(Color::Gray),
565594
)),
566595
];

0 commit comments

Comments
Β (0)