1use crate::config::Config;
2use crate::content::types::{Difficulty, Exercise, ModuleFile};
3use crate::executor::{ExecutionResult, Executor};
4use crate::matcher::Matcher;
5use crate::progress::{ModuleProgress, Progress};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ContentView {
9 Intro,
10 Examples,
11 Exercise,
12 FreePractice,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SubmitState {
18 Idle,
19 Correct,
20 Wrong,
21 Error,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum DifficultyFilter {
27 None,
28 Beginner,
29 Intermediate,
30 Advanced,
31}
32
33impl std::fmt::Display for DifficultyFilter {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 DifficultyFilter::None => write!(f, "All"),
37 DifficultyFilter::Beginner => write!(f, "Beginner"),
38 DifficultyFilter::Intermediate => write!(f, "Intermediate"),
39 DifficultyFilter::Advanced => write!(f, "Advanced"),
40 }
41 }
42}
43
44pub struct App {
45 pub modules: Vec<ModuleFile>,
46 pub selected_module: usize,
47 pub current_view: ContentView,
48 pub current_exercise: usize,
49
50 pub input: String,
51 pub cursor_pos: usize,
52
53 pub last_output: Option<ExecutionResult>,
54 pub submit_state: SubmitState,
55
56 pub hints_revealed: usize,
57 pub show_solution: bool,
58 pub show_files: bool,
59 pub show_help: bool,
60
61 pub intro_scroll: u16,
62 pub examples_scroll: u16,
63 pub output_scroll: u16,
64
65 pub progress: Progress,
66 pub should_quit: bool,
67
68 pub config: Config,
70
71 pub command_history: Vec<String>,
73 pub history_idx: Option<usize>,
74 history_draft: String,
75
76 pub search_active: bool,
78 pub search_query: String,
79 pub search_filtered: Vec<usize>,
80
81 pub show_progress: bool,
83
84 pub difficulty_filter: DifficultyFilter,
86
87 pub timer_start: Option<std::time::Instant>,
89 pub last_solve_ms: Option<u64>,
90}
91
92impl App {
93 pub fn new(modules: Vec<ModuleFile>, config: Config) -> Self {
94 let progress = Progress::load();
95
96 let selected_module = config
98 .default_module
99 .as_deref()
100 .and_then(|name| modules.iter().position(|m| m.module.name == name))
101 .unwrap_or(0);
102
103 App {
104 modules,
105 selected_module,
106 current_view: ContentView::Intro,
107 current_exercise: 0,
108 input: String::new(),
109 cursor_pos: 0,
110 last_output: None,
111 submit_state: SubmitState::Idle,
112 hints_revealed: 0,
113 show_solution: false,
114 show_files: false,
115 show_help: false,
116 intro_scroll: 0,
117 examples_scroll: 0,
118 output_scroll: 0,
119 progress,
120 should_quit: false,
121 config,
122 command_history: Vec::new(),
123 history_idx: None,
124 history_draft: String::new(),
125 search_active: false,
126 search_query: String::new(),
127 search_filtered: Vec::new(),
128 show_progress: false,
129 difficulty_filter: DifficultyFilter::None,
130 timer_start: None,
131 last_solve_ms: None,
132 }
133 }
134
135 pub fn current_module(&self) -> &ModuleFile {
136 &self.modules[self.selected_module]
137 }
138
139 pub fn current_exercise_opt(&self) -> Option<&Exercise> {
140 self.current_module().exercises.get(self.current_exercise)
141 }
142
143 pub fn exercise_count(&self) -> usize {
144 self.current_module().exercises.len()
145 }
146
147 pub fn module_progress(&self) -> Option<&ModuleProgress> {
148 let name = &self.current_module().module.name;
149 self.progress.modules.get(name)
150 }
151
152 pub fn exercise_is_completed(&self) -> bool {
153 if let Some(ex) = self.current_exercise_opt() {
154 let module_name = &self.current_module().module.name;
155 self.progress.is_completed(module_name, &ex.id)
156 } else {
157 false
158 }
159 }
160
161 pub fn visible_module_indices(&self) -> Vec<usize> {
163 if self.search_active && !self.search_query.is_empty() {
164 self.search_filtered.clone()
165 } else {
166 (0..self.modules.len()).collect()
167 }
168 }
169
170 pub fn input_push(&mut self, c: char) {
173 if self.config.timed_challenge
175 && self.current_view == ContentView::Exercise
176 && self.timer_start.is_none()
177 {
178 self.timer_start = Some(std::time::Instant::now());
179 }
180 self.input.insert(self.cursor_pos, c);
181 self.cursor_pos += c.len_utf8();
182 self.history_idx = None;
184 }
185
186 pub fn input_backspace(&mut self) {
187 if self.cursor_pos > 0 {
188 let prev = self.input[..self.cursor_pos]
189 .char_indices()
190 .last()
191 .map(|(i, _)| i)
192 .unwrap_or(0);
193 self.input.remove(prev);
194 self.cursor_pos = prev;
195 }
196 self.history_idx = None;
197 }
198
199 pub fn input_delete(&mut self) {
200 if self.cursor_pos < self.input.len() {
201 self.input.remove(self.cursor_pos);
202 }
203 self.history_idx = None;
204 }
205
206 pub fn input_paste(&mut self, s: &str) {
208 for c in s.chars() {
209 if !c.is_control() {
210 self.input.insert(self.cursor_pos, c);
211 self.cursor_pos += c.len_utf8();
212 }
213 }
214 self.history_idx = None;
215 }
216
217 pub fn cursor_left(&mut self) {
218 if self.cursor_pos > 0 {
219 self.cursor_pos = self.input[..self.cursor_pos]
220 .char_indices()
221 .last()
222 .map(|(i, _)| i)
223 .unwrap_or(0);
224 }
225 }
226
227 pub fn cursor_right(&mut self) {
228 if self.cursor_pos < self.input.len() {
229 let mut chars = self.input[self.cursor_pos..].char_indices();
230 if let Some((_, c)) = chars.next() {
231 self.cursor_pos += c.len_utf8();
232 }
233 }
234 }
235
236 pub fn cursor_word_left(&mut self) {
238 let mut pos = self.cursor_pos;
239 while pos > 0 {
241 let c = self.input[..pos].chars().last().unwrap();
242 if c.is_alphanumeric() || c == '_' {
243 break;
244 }
245 pos -= c.len_utf8();
246 }
247 while pos > 0 {
249 let c = self.input[..pos].chars().last().unwrap();
250 if !c.is_alphanumeric() && c != '_' {
251 break;
252 }
253 pos -= c.len_utf8();
254 }
255 self.cursor_pos = pos;
256 }
257
258 pub fn cursor_word_right(&mut self) {
260 let mut pos = self.cursor_pos;
261 let len = self.input.len();
262 while pos < len {
264 let c = self.input[pos..].chars().next().unwrap();
265 if c.is_alphanumeric() || c == '_' {
266 break;
267 }
268 pos += c.len_utf8();
269 }
270 while pos < len {
272 let c = self.input[pos..].chars().next().unwrap();
273 if !c.is_alphanumeric() && c != '_' {
274 break;
275 }
276 pos += c.len_utf8();
277 }
278 self.cursor_pos = pos;
279 }
280
281 pub fn cursor_home(&mut self) {
282 self.cursor_pos = 0;
283 }
284
285 pub fn cursor_end(&mut self) {
286 self.cursor_pos = self.input.len();
287 }
288
289 pub fn clear_input(&mut self) {
290 self.input.clear();
291 self.cursor_pos = 0;
292 self.history_idx = None;
293 }
294
295 fn push_history(&mut self, cmd: &str) {
299 let cmd = cmd.trim().to_string();
300 if cmd.is_empty() {
301 return;
302 }
303 if self.command_history.last().map(|s| s == &cmd).unwrap_or(false) {
304 return;
305 }
306 self.command_history.push(cmd);
307 self.history_idx = None;
308 self.history_draft.clear();
309 }
310
311 pub fn history_prev(&mut self) {
313 if self.command_history.is_empty() {
314 return;
315 }
316 match self.history_idx {
317 None => {
318 self.history_draft = self.input.clone();
319 self.history_idx = Some(self.command_history.len() - 1);
320 }
321 Some(0) => return,
322 Some(i) => {
323 self.history_idx = Some(i - 1);
324 }
325 }
326 if let Some(i) = self.history_idx {
327 self.input = self.command_history[i].clone();
328 self.cursor_pos = self.input.len();
329 }
330 }
331
332 pub fn history_next(&mut self) {
334 match self.history_idx {
335 None => {}
336 Some(i) if i + 1 >= self.command_history.len() => {
337 self.history_idx = None;
338 self.input = self.history_draft.clone();
339 self.cursor_pos = self.input.len();
340 }
341 Some(i) => {
342 self.history_idx = Some(i + 1);
343 self.input = self.command_history[i + 1].clone();
344 self.cursor_pos = self.input.len();
345 }
346 }
347 }
348
349 pub fn activate_search(&mut self) {
353 self.search_active = true;
354 self.search_query.clear();
355 self.search_filtered = (0..self.modules.len()).collect();
356 }
357
358 pub fn search_push(&mut self, c: char) {
360 self.search_query.push(c);
361 self.update_search_filtered();
362 if let Some(&first) = self.search_filtered.first() {
363 self.selected_module = first;
364 }
365 }
366
367 pub fn search_backspace(&mut self) {
368 self.search_query.pop();
369 self.update_search_filtered();
370 if let Some(&first) = self.search_filtered.first() {
371 self.selected_module = first;
372 }
373 }
374
375 pub fn search_confirm(&mut self) {
377 if let Some(&first) = self.search_filtered.first() {
378 self.selected_module = first;
379 self.reset_content_state();
380 }
381 self.search_active = false;
382 self.search_query.clear();
383 }
384
385 pub fn search_cancel(&mut self) {
387 self.search_active = false;
388 self.search_query.clear();
389 self.search_filtered.clear();
390 }
391
392 fn update_search_filtered(&mut self) {
393 let q = self.search_query.to_lowercase();
394 self.search_filtered = self
395 .modules
396 .iter()
397 .enumerate()
398 .filter(|(_, m)| m.module.name.to_lowercase().contains(&q))
399 .map(|(i, _)| i)
400 .collect();
401 }
402
403 pub fn select_prev_module(&mut self) {
406 if self.selected_module > 0 {
407 self.selected_module -= 1;
408 self.reset_content_state();
409 }
410 }
411
412 pub fn select_next_module(&mut self) {
413 if self.selected_module + 1 < self.modules.len() {
414 self.selected_module += 1;
415 self.reset_content_state();
416 }
417 }
418
419 pub fn cycle_view(&mut self) {
421 self.current_view = match self.current_view {
422 ContentView::Intro => ContentView::Examples,
423 ContentView::Examples => ContentView::Exercise,
424 ContentView::Exercise => ContentView::FreePractice,
425 ContentView::FreePractice => ContentView::Intro,
426 };
427 }
428
429 pub fn cycle_difficulty_filter(&mut self) {
431 self.difficulty_filter = match self.difficulty_filter {
432 DifficultyFilter::None => DifficultyFilter::Beginner,
433 DifficultyFilter::Beginner => DifficultyFilter::Intermediate,
434 DifficultyFilter::Intermediate => DifficultyFilter::Advanced,
435 DifficultyFilter::Advanced => DifficultyFilter::None,
436 };
437 if self.current_view == ContentView::Exercise {
439 self.jump_to_first_matching_exercise();
440 }
441 }
442
443 pub fn next_exercise(&mut self) {
445 let count = self.exercise_count();
446 if count == 0 {
447 return;
448 }
449 let mut candidate = self.current_exercise;
450 loop {
451 if candidate + 1 >= count {
452 break;
453 }
454 candidate += 1;
455 if self.exercise_matches_filter(candidate) {
456 self.current_exercise = candidate;
457 self.reset_exercise_state();
458 return;
459 }
460 }
461 }
462
463 pub fn prev_exercise(&mut self) {
464 if self.current_exercise == 0 {
465 return;
466 }
467 let mut candidate = self.current_exercise;
468 loop {
469 if candidate == 0 {
470 break;
471 }
472 candidate -= 1;
473 if self.exercise_matches_filter(candidate) {
474 self.current_exercise = candidate;
475 self.reset_exercise_state();
476 return;
477 }
478 }
479 }
480
481 fn exercise_matches_filter(&self, idx: usize) -> bool {
482 let ex = match self.current_module().exercises.get(idx) {
483 Some(e) => e,
484 None => return false,
485 };
486 if self.config.skip_completed {
488 let module_name = &self.current_module().module.name;
489 if self.progress.is_completed(module_name, &ex.id) {
490 return false;
491 }
492 }
493 match self.difficulty_filter {
494 DifficultyFilter::None => true,
495 DifficultyFilter::Beginner => ex.difficulty == Difficulty::Beginner,
496 DifficultyFilter::Intermediate => ex.difficulty == Difficulty::Intermediate,
497 DifficultyFilter::Advanced => ex.difficulty == Difficulty::Advanced,
498 }
499 }
500
501 fn jump_to_first_matching_exercise(&mut self) {
502 let count = self.exercise_count();
503 for i in 0..count {
504 if self.exercise_matches_filter(i) {
505 self.current_exercise = i;
506 return;
507 }
508 }
509 }
510
511 pub fn submit_command(&mut self) {
514 let command = self.input.clone();
515 if command.trim().is_empty() {
516 return;
517 }
518
519 self.push_history(&command);
521
522 let exercise = match self.current_exercise_opt() {
523 Some(ex) => ex.clone(),
524 None => return,
525 };
526
527 let result = Executor::run(&command, &exercise.fixtures);
528 let (exec_result, exec_error) = match result {
529 Ok(o) => (Some(o), false),
530 Err(e) => (
531 Some(ExecutionResult {
532 stdout: String::new(),
533 stderr: e.to_string(),
534 timed_out: false,
535 }),
536 true,
537 ),
538 };
539
540 let correct = exec_result
541 .as_ref()
542 .map(|o| {
543 !exec_error
544 && Matcher::check(&o.stdout, &exercise.expected_output, &exercise.match_mode)
545 })
546 .unwrap_or(false);
547
548 self.last_output = exec_result;
549 self.output_scroll = 0;
550
551 let module_name = self.current_module().module.name.clone();
552 if exec_error {
553 self.submit_state = SubmitState::Error;
554 } else if correct {
555 self.submit_state = SubmitState::Correct;
556 self.progress.mark_completed(&module_name, &exercise.id);
557
558 if self.config.timed_challenge {
560 if let Some(start) = self.timer_start.take() {
561 let ms = start.elapsed().as_millis() as u64;
562 self.last_solve_ms = Some(ms);
563 self.progress.record_time(&module_name, &exercise.id, ms);
564 }
565 }
566
567 let _ = self.progress.save();
568 } else {
569 self.submit_state = SubmitState::Wrong;
570 self.progress.mark_attempted(&module_name, &exercise.id);
571 }
572 }
573
574 pub fn submit_command_free(&mut self) {
576 let command = self.input.clone();
577 if command.trim().is_empty() {
578 return;
579 }
580 self.push_history(&command);
581
582 let result = Executor::run(&command, &[]);
583 self.last_output = match result {
584 Ok(o) => Some(o),
585 Err(e) => Some(ExecutionResult {
586 stdout: String::new(),
587 stderr: e.to_string(),
588 timed_out: false,
589 }),
590 };
591 self.submit_state = SubmitState::Idle;
592 self.output_scroll = 0;
593 }
594
595 pub fn reveal_next_hint(&mut self) {
596 if let Some(ex) = self.current_exercise_opt() {
597 if self.hints_revealed < ex.hints.len() {
598 self.hints_revealed += 1;
599 }
600 }
601 }
602
603 pub fn toggle_solution(&mut self) {
604 self.show_solution = !self.show_solution;
605 }
606
607 pub fn toggle_files(&mut self) {
608 self.show_files = !self.show_files;
609 }
610
611 pub fn toggle_help(&mut self) {
612 self.show_help = !self.show_help;
613 }
614
615 pub fn toggle_progress(&mut self) {
617 self.show_progress = !self.show_progress;
618 }
619
620 pub fn reset_exercise(&mut self) {
621 self.reset_exercise_state();
622 }
623
624 pub fn clear_output(&mut self) {
625 self.last_output = None;
626 self.submit_state = SubmitState::Idle;
627 self.output_scroll = 0;
628 }
629
630 pub fn scroll_up(&mut self) {
633 match self.current_view {
634 ContentView::Intro => self.intro_scroll = self.intro_scroll.saturating_sub(1),
635 ContentView::Examples => {
636 self.examples_scroll = self.examples_scroll.saturating_sub(1)
637 }
638 ContentView::Exercise | ContentView::FreePractice => {
639 self.output_scroll = self.output_scroll.saturating_sub(1)
640 }
641 }
642 }
643
644 pub fn scroll_down(&mut self) {
645 match self.current_view {
646 ContentView::Intro => self.intro_scroll += 1,
647 ContentView::Examples => self.examples_scroll += 1,
648 ContentView::Exercise | ContentView::FreePractice => self.output_scroll += 1,
649 }
650 }
651
652 fn reset_content_state(&mut self) {
655 self.current_view = ContentView::Intro;
656 self.current_exercise = 0;
657 self.reset_exercise_state();
658 self.intro_scroll = 0;
659 self.examples_scroll = 0;
660 self.difficulty_filter = DifficultyFilter::None;
662 }
663
664 fn reset_exercise_state(&mut self) {
665 self.clear_input();
666 self.last_output = None;
667 self.submit_state = SubmitState::Idle;
668 self.hints_revealed = 0;
669 self.show_solution = false;
670 self.show_files = false;
671 self.output_scroll = 0;
672 self.timer_start = None;
674 self.last_solve_ms = None;
675 }
676}