Skip to main content

cli_tutor/
app.rs

1use crate::content::types::{Exercise, ModuleFile};
2use crate::executor::{ExecutionResult, Executor};
3use crate::matcher::Matcher;
4use crate::progress::{ModuleProgress, Progress};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ContentView {
8    Intro,
9    Examples,
10    Exercise,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SubmitState {
15    Idle,
16    Correct,
17    Wrong,
18    Error,
19}
20
21pub struct App {
22    pub modules: Vec<ModuleFile>,
23    pub selected_module: usize,
24    pub current_view: ContentView,
25    pub current_exercise: usize,
26
27    pub input: String,
28    pub cursor_pos: usize,
29
30    pub last_output: Option<ExecutionResult>,
31    pub submit_state: SubmitState,
32
33    pub hints_revealed: usize,
34    pub show_solution: bool,
35    pub show_files: bool,
36    pub show_help: bool,
37
38    pub intro_scroll: u16,
39    pub examples_scroll: u16,
40    pub output_scroll: u16,
41
42    pub progress: Progress,
43    pub should_quit: bool,
44}
45
46impl App {
47    pub fn new(modules: Vec<ModuleFile>) -> Self {
48        let progress = Progress::load();
49        App {
50            modules,
51            selected_module: 0,
52            current_view: ContentView::Intro,
53            current_exercise: 0,
54            input: String::new(),
55            cursor_pos: 0,
56            last_output: None,
57            submit_state: SubmitState::Idle,
58            hints_revealed: 0,
59            show_solution: false,
60            show_files: false,
61            show_help: false,
62            intro_scroll: 0,
63            examples_scroll: 0,
64            output_scroll: 0,
65            progress,
66            should_quit: false,
67        }
68    }
69
70    pub fn current_module(&self) -> &ModuleFile {
71        &self.modules[self.selected_module]
72    }
73
74    pub fn current_exercise_opt(&self) -> Option<&Exercise> {
75        self.current_module().exercises.get(self.current_exercise)
76    }
77
78    pub fn exercise_count(&self) -> usize {
79        self.current_module().exercises.len()
80    }
81
82    pub fn module_progress(&self) -> Option<&ModuleProgress> {
83        let name = &self.current_module().module.name;
84        self.progress.modules.get(name)
85    }
86
87    pub fn exercise_is_completed(&self) -> bool {
88        if let Some(ex) = self.current_exercise_opt() {
89            let module_name = &self.current_module().module.name;
90            self.progress.is_completed(module_name, &ex.id)
91        } else {
92            false
93        }
94    }
95
96    // --- Input handling ---
97
98    pub fn input_push(&mut self, c: char) {
99        self.input.insert(self.cursor_pos, c);
100        self.cursor_pos += c.len_utf8();
101    }
102
103    pub fn input_backspace(&mut self) {
104        if self.cursor_pos > 0 {
105            let prev = self.input[..self.cursor_pos]
106                .char_indices()
107                .last()
108                .map(|(i, _)| i)
109                .unwrap_or(0);
110            self.input.remove(prev);
111            self.cursor_pos = prev;
112        }
113    }
114
115    pub fn input_delete(&mut self) {
116        if self.cursor_pos < self.input.len() {
117            self.input.remove(self.cursor_pos);
118        }
119    }
120
121    pub fn cursor_left(&mut self) {
122        if self.cursor_pos > 0 {
123            self.cursor_pos = self.input[..self.cursor_pos]
124                .char_indices()
125                .last()
126                .map(|(i, _)| i)
127                .unwrap_or(0);
128        }
129    }
130
131    pub fn cursor_right(&mut self) {
132        if self.cursor_pos < self.input.len() {
133            let mut chars = self.input[self.cursor_pos..].char_indices();
134            if let Some((_, c)) = chars.next() {
135                self.cursor_pos += c.len_utf8();
136            }
137        }
138    }
139
140    pub fn cursor_home(&mut self) {
141        self.cursor_pos = 0;
142    }
143
144    pub fn cursor_end(&mut self) {
145        self.cursor_pos = self.input.len();
146    }
147
148    pub fn clear_input(&mut self) {
149        self.input.clear();
150        self.cursor_pos = 0;
151    }
152
153    // --- Navigation ---
154
155    pub fn select_prev_module(&mut self) {
156        if self.selected_module > 0 {
157            self.selected_module -= 1;
158            self.reset_content_state();
159        }
160    }
161
162    pub fn select_next_module(&mut self) {
163        if self.selected_module + 1 < self.modules.len() {
164            self.selected_module += 1;
165            self.reset_content_state();
166        }
167    }
168
169    pub fn cycle_view(&mut self) {
170        self.current_view = match self.current_view {
171            ContentView::Intro => ContentView::Examples,
172            ContentView::Examples => ContentView::Exercise,
173            ContentView::Exercise => ContentView::Intro,
174        };
175    }
176
177    pub fn next_exercise(&mut self) {
178        let count = self.exercise_count();
179        if count > 0 && self.current_exercise + 1 < count {
180            self.current_exercise += 1;
181            self.reset_exercise_state();
182        }
183    }
184
185    pub fn prev_exercise(&mut self) {
186        if self.current_exercise > 0 {
187            self.current_exercise -= 1;
188            self.reset_exercise_state();
189        }
190    }
191
192    // --- Exercise actions ---
193
194    pub fn submit_command(&mut self) {
195        let command = self.input.clone();
196        if command.trim().is_empty() {
197            return;
198        }
199
200        let exercise = match self.current_exercise_opt() {
201            Some(ex) => ex.clone(),
202            None => return,
203        };
204
205        let result = Executor::run(&command, &exercise.fixtures);
206        let (exec_result, exec_error) = match result {
207            Ok(o) => (Some(o), false),
208            Err(e) => (
209                Some(ExecutionResult {
210                    stdout: String::new(),
211                    stderr: e.to_string(),
212                    timed_out: false,
213                }),
214                true,
215            ),
216        };
217
218        let correct = exec_result
219            .as_ref()
220            .map(|o| {
221                !exec_error
222                    && Matcher::check(&o.stdout, &exercise.expected_output, &exercise.match_mode)
223            })
224            .unwrap_or(false);
225
226        self.last_output = exec_result;
227        self.output_scroll = 0;
228
229        let module_name = self.current_module().module.name.clone();
230        if exec_error {
231            self.submit_state = SubmitState::Error;
232        } else if correct {
233            self.submit_state = SubmitState::Correct;
234            self.progress.mark_completed(&module_name, &exercise.id);
235            let _ = self.progress.save();
236        } else {
237            self.submit_state = SubmitState::Wrong;
238            self.progress.mark_attempted(&module_name, &exercise.id);
239        }
240    }
241
242    pub fn reveal_next_hint(&mut self) {
243        if let Some(ex) = self.current_exercise_opt() {
244            if self.hints_revealed < ex.hints.len() {
245                self.hints_revealed += 1;
246            }
247        }
248    }
249
250    pub fn toggle_solution(&mut self) {
251        self.show_solution = !self.show_solution;
252    }
253
254    pub fn toggle_files(&mut self) {
255        self.show_files = !self.show_files;
256    }
257
258    pub fn toggle_help(&mut self) {
259        self.show_help = !self.show_help;
260    }
261
262    pub fn reset_exercise(&mut self) {
263        self.reset_exercise_state();
264    }
265
266    pub fn clear_output(&mut self) {
267        self.last_output = None;
268        self.submit_state = SubmitState::Idle;
269        self.output_scroll = 0;
270    }
271
272    // --- Scrolling ---
273
274    pub fn scroll_up(&mut self) {
275        match self.current_view {
276            ContentView::Intro => self.intro_scroll = self.intro_scroll.saturating_sub(1),
277            ContentView::Examples => self.examples_scroll = self.examples_scroll.saturating_sub(1),
278            ContentView::Exercise => self.output_scroll = self.output_scroll.saturating_sub(1),
279        }
280    }
281
282    pub fn scroll_down(&mut self) {
283        match self.current_view {
284            ContentView::Intro => self.intro_scroll += 1,
285            ContentView::Examples => self.examples_scroll += 1,
286            ContentView::Exercise => self.output_scroll += 1,
287        }
288    }
289
290    // --- Helpers ---
291
292    fn reset_content_state(&mut self) {
293        self.current_view = ContentView::Intro;
294        self.current_exercise = 0;
295        self.reset_exercise_state();
296        self.intro_scroll = 0;
297        self.examples_scroll = 0;
298    }
299
300    fn reset_exercise_state(&mut self) {
301        self.clear_input();
302        self.last_output = None;
303        self.submit_state = SubmitState::Idle;
304        self.hints_revealed = 0;
305        self.show_solution = false;
306        self.show_files = false;
307        self.output_scroll = 0;
308    }
309}