arct_tui/panels/
lesson_menu.rs

1//! Lesson selection menu panel
2
3use crate::icons;
4use crate::theme::Theme;
5use arct_core::{Lesson, LessonLibrary};
6use ratatui::{
7    layout::{Alignment, Constraint, Direction, Layout, Rect},
8    text::{Line, Span},
9    widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
10    Frame,
11};
12use std::collections::HashSet;
13
14/// Lesson menu panel for selecting lessons
15pub struct LessonMenuPanel {
16    library: LessonLibrary,
17    selected_index: usize,
18    scroll_offset: usize,
19}
20
21impl LessonMenuPanel {
22    pub fn new() -> Self {
23        Self {
24            library: LessonLibrary::new(),
25            selected_index: 0,
26            scroll_offset: 0,
27        }
28    }
29
30    /// Move selection up
31    pub fn select_previous(&mut self) {
32        let total = self.library.all().len();
33        if total > 0 {
34            self.selected_index = if self.selected_index == 0 {
35                total - 1
36            } else {
37                self.selected_index - 1
38            };
39        }
40    }
41
42    /// Move selection down
43    pub fn select_next(&mut self) {
44        let total = self.library.all().len();
45        if total > 0 {
46            self.selected_index = (self.selected_index + 1) % total;
47        }
48    }
49
50    /// Ensure selected item is visible within the given height
51    fn ensure_visible(&mut self, visible_height: usize) {
52        // Each lesson takes 3 lines (title + description + blank line)
53        let lines_per_lesson = 3;
54        let visible_lessons = visible_height / lines_per_lesson;
55
56        if visible_lessons == 0 {
57            return;
58        }
59
60        // If selected is below visible area, scroll down
61        if self.selected_index >= self.scroll_offset + visible_lessons {
62            self.scroll_offset = self.selected_index.saturating_sub(visible_lessons - 1);
63        }
64
65        // If selected is above visible area, scroll up
66        if self.selected_index < self.scroll_offset {
67            self.scroll_offset = self.selected_index;
68        }
69    }
70
71    /// Select lesson by number (1-indexed for user display)
72    pub fn select_by_number(&mut self, number: usize) {
73        let total = self.library.all().len();
74        if number > 0 && number <= total {
75            self.selected_index = number - 1;
76        }
77    }
78
79    /// Get currently selected lesson
80    pub fn get_selected_lesson(&self) -> Option<Lesson> {
81        let lessons = self.library.all();
82        lessons.get(self.selected_index).map(|&l| l.clone())
83    }
84
85    /// Render the lesson menu overlay (centered popup)
86    pub fn render(
87        &mut self,
88        frame: &mut Frame,
89        theme: &Theme,
90        completed_lessons: &HashSet<String>,
91        user_stats: &arct_core::UserStats,
92        recommendation_engine: &arct_core::RecommendationEngine,
93    ) {
94        let area = Self::centered_rect(70, 60, frame.size());
95
96        // Clear the background
97        frame.render_widget(Clear, area);
98
99        let block = Block::default()
100            .title(format!(" {}Lesson Selection Menu ", icons::lesson().content))
101            .title_alignment(Alignment::Center)
102            .borders(Borders::ALL)
103            .border_style(theme.style_border_focused())
104            .style(theme.style_block());  // Set background for light themes
105
106        let inner = block.inner(area);
107        frame.render_widget(block, area);
108
109        // Split into sections
110        let chunks = Layout::default()
111            .direction(Direction::Vertical)
112            .constraints([
113                Constraint::Length(3),   // Header info
114                Constraint::Length(8),   // Recommended lessons
115                Constraint::Min(10),     // Lesson list
116                Constraint::Length(4),   // Controls help
117            ])
118            .split(inner);
119
120        // Header
121        let header = Paragraph::new(vec![
122            Line::from(vec![
123                Span::styled("Select a lesson to begin your learning journey!", theme.style_normal()),
124            ]),
125            Line::from(vec![
126                Span::styled("Completed lessons are marked with ", theme.style_dim()),
127                icons::celebration(),
128            ]),
129        ])
130        .alignment(Alignment::Center);
131        frame.render_widget(header, chunks[0]);
132
133        // Recommended lessons section
134        let recommendations = recommendation_engine.get_recommendations(
135            completed_lessons,
136            user_stats,
137            3,  // Show top 3 recommendations
138        );
139
140        let mut rec_items = Vec::new();
141        rec_items.push(ListItem::new(Line::from(vec![
142            icons::target(),
143            Span::styled(" Recommended for You", theme.style_header()),
144        ])));
145        rec_items.push(ListItem::new(Line::from("")));
146
147        if recommendations.is_empty() {
148            rec_items.push(ListItem::new(Line::from(vec![
149                Span::styled("  No recommendations yet. Complete some lessons to get started!", theme.style_dim()),
150            ])));
151        } else {
152            for rec in recommendations {
153                let reason_text = match rec.reason {
154                    arct_core::RecommendationReason::NextInSequence => "Next in sequence",
155                    arct_core::RecommendationReason::PrerequisiteSatisfied => "Prerequisites met!",
156                    arct_core::RecommendationReason::SameDifficulty => "Matches your level",
157                    arct_core::RecommendationReason::SkillLevelMatch => "Perfect for you",
158                    arct_core::RecommendationReason::RelatedTopic => "Related topic",
159                    _ => "Recommended",
160                };
161
162                let lesson_title = rec.lesson.title.clone();
163                rec_items.push(ListItem::new(Line::from(vec![
164                    Span::raw("  "),
165                    icons::lightning(),
166                    Span::styled(lesson_title, theme.style_accent()),
167                    Span::raw("  "),
168                    Span::styled(format!("({})", reason_text), theme.style_dim()),
169                ])));
170            }
171        }
172
173        let rec_list = List::new(rec_items);
174        frame.render_widget(rec_list, chunks[1]);
175
176        // Lesson list with scrolling
177        let list_area_height = chunks[2].height as usize;
178
179        // Ensure selected item is visible (do this before borrowing lessons)
180        self.ensure_visible(list_area_height);
181
182        let lessons = self.library.all();
183
184        let mut items = Vec::new();
185
186        // Calculate visible range
187        let lines_per_lesson = 3;
188        let visible_lessons = list_area_height / lines_per_lesson;
189        let end_idx = (self.scroll_offset + visible_lessons).min(lessons.len());
190
191        // Add scroll indicator at top if scrolled down
192        if self.scroll_offset > 0 {
193            items.push(ListItem::new(Line::from(vec![
194                Span::styled("▲ ▲ ▲  Scroll up for more lessons  ▲ ▲ ▲", theme.style_dim()),
195            ])));
196        }
197
198        for (idx, lesson) in lessons.iter().enumerate().skip(self.scroll_offset).take(end_idx - self.scroll_offset) {
199            let is_selected = idx == self.selected_index;
200            let is_completed = completed_lessons.contains(&lesson.id);
201
202            let number = format!("[{}] ", idx + 1);
203            let status_icon = if is_completed {
204                icons::celebration()
205            } else {
206                icons::lesson()
207            };
208
209            let difficulty_text = format!("{:?}", lesson.difficulty);
210            let time_text = format!("{}min", lesson.estimated_minutes);
211
212            let mut line_spans = vec![
213                Span::styled(number, theme.style_accent()),
214                status_icon,
215                Span::styled(&lesson.title, if is_selected {
216                    theme.style_accent().add_modifier(ratatui::style::Modifier::BOLD)
217                } else {
218                    theme.style_normal()
219                }),
220            ];
221
222            // Add difficulty and time
223            let padding = " ".repeat(40_usize.saturating_sub(lesson.title.len()));
224            line_spans.push(Span::raw(padding));
225            line_spans.push(Span::styled(
226                format!(" {} | {} ", difficulty_text, time_text),
227                theme.style_dim(),
228            ));
229
230            let mut description_line = vec![Span::raw("    ")];
231            if is_completed {
232                description_line.push(icons::success());
233                description_line.push(Span::styled("Completed  ", theme.style_success()));
234            }
235            description_line.push(Span::styled(&lesson.description, theme.style_dim()));
236
237            items.push(ListItem::new(vec![
238                Line::from(line_spans),
239                Line::from(description_line),
240                Line::from(""),
241            ]));
242        }
243
244        // Add scroll indicator at bottom if there are more lessons below
245        if end_idx < lessons.len() {
246            items.push(ListItem::new(Line::from(vec![
247                Span::styled("▼ ▼ ▼  Scroll down for more lessons  ▼ ▼ ▼", theme.style_dim()),
248            ])));
249        }
250
251        let list = List::new(items);
252        frame.render_widget(list, chunks[2]);
253
254        // Controls help
255        let total_lessons = lessons.len();
256        let quick_select_text = if total_lessons <= 10 {
257            vec![
258                Span::styled("1-9", theme.style_accent()),
259                Span::raw(" for lessons 1-9, "),
260                Span::styled("0", theme.style_accent()),
261                Span::raw(" for lesson 10  |  "),
262            ]
263        } else {
264            vec![
265                Span::styled("1-9,0", theme.style_accent()),
266                Span::raw(" for first 10, use "),
267                Span::styled("↑/↓", theme.style_accent()),
268                Span::raw(" for others  |  "),
269            ]
270        };
271
272        let mut control_line1 = vec![
273            Span::styled("↑/↓", theme.style_accent()),
274            Span::raw(" or "),
275            Span::styled("j/k", theme.style_accent()),
276            Span::raw(" navigate  |  "),
277        ];
278        control_line1.extend(quick_select_text);
279        control_line1.push(Span::styled("Enter", theme.style_accent()));
280        control_line1.push(Span::raw(" start"));
281
282        let controls = Paragraph::new(vec![
283            Line::from(control_line1),
284            Line::from(vec![
285                Span::styled("Esc", theme.style_accent()),
286                Span::raw(" or "),
287                Span::styled("q", theme.style_accent()),
288                Span::raw(" close menu"),
289            ]),
290        ])
291        .alignment(Alignment::Center);
292        frame.render_widget(controls, chunks[3]);
293    }
294
295    /// Helper function to create a centered rectangle
296    fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
297        let popup_layout = Layout::default()
298            .direction(Direction::Vertical)
299            .constraints([
300                Constraint::Percentage((100 - percent_y) / 2),
301                Constraint::Percentage(percent_y),
302                Constraint::Percentage((100 - percent_y) / 2),
303            ])
304            .split(r);
305
306        Layout::default()
307            .direction(Direction::Horizontal)
308            .constraints([
309                Constraint::Percentage((100 - percent_x) / 2),
310                Constraint::Percentage(percent_x),
311                Constraint::Percentage((100 - percent_x) / 2),
312            ])
313            .split(popup_layout[1])[1]
314    }
315}
316
317impl Default for LessonMenuPanel {
318    fn default() -> Self {
319        Self::new()
320    }
321}