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, Stats};
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 stats: Stats,
68 pub should_quit: bool,
69
70 pub config: Config,
72
73 pub command_history: Vec<String>,
75 pub history_idx: Option<usize>,
76 history_draft: String,
77
78 pub search_active: bool,
80 pub search_query: String,
81 pub search_filtered: Vec<usize>,
82
83 pub show_progress: bool,
85
86 pub difficulty_filter: DifficultyFilter,
88
89 pub timer_start: Option<std::time::Instant>,
91 pub last_solve_ms: Option<u64>,
92}
93
94impl App {
95 pub fn new(modules: Vec<ModuleFile>, config: Config) -> Self {
96 let progress = Progress::load();
97 let stats = Stats::load();
98
99 let selected_module = config
101 .default_module
102 .as_deref()
103 .and_then(|name| modules.iter().position(|m| m.module.name == name))
104 .unwrap_or(0);
105
106 App {
107 modules,
108 selected_module,
109 current_view: ContentView::Intro,
110 current_exercise: 0,
111 input: String::new(),
112 cursor_pos: 0,
113 last_output: None,
114 submit_state: SubmitState::Idle,
115 hints_revealed: 0,
116 show_solution: false,
117 show_files: false,
118 show_help: false,
119 intro_scroll: 0,
120 examples_scroll: 0,
121 output_scroll: 0,
122 progress,
123 stats,
124 should_quit: false,
125 config,
126 command_history: Vec::new(),
127 history_idx: None,
128 history_draft: String::new(),
129 search_active: false,
130 search_query: String::new(),
131 search_filtered: Vec::new(),
132 show_progress: false,
133 difficulty_filter: DifficultyFilter::None,
134 timer_start: None,
135 last_solve_ms: None,
136 }
137 }
138
139 pub fn current_module(&self) -> &ModuleFile {
140 &self.modules[self.selected_module]
141 }
142
143 pub fn current_exercise_opt(&self) -> Option<&Exercise> {
144 self.current_module().exercises.get(self.current_exercise)
145 }
146
147 pub fn exercise_count(&self) -> usize {
148 self.current_module().exercises.len()
149 }
150
151 pub fn module_progress(&self) -> Option<&ModuleProgress> {
152 let name = &self.current_module().module.name;
153 self.progress.modules.get(name)
154 }
155
156 pub fn exercise_is_completed(&self) -> bool {
157 if let Some(ex) = self.current_exercise_opt() {
158 let module_name = &self.current_module().module.name;
159 self.progress.is_completed(module_name, &ex.id)
160 } else {
161 false
162 }
163 }
164
165 pub fn visible_module_indices(&self) -> Vec<usize> {
167 if self.search_active && !self.search_query.is_empty() {
168 self.search_filtered.clone()
169 } else {
170 (0..self.modules.len()).collect()
171 }
172 }
173
174 pub fn input_push(&mut self, c: char) {
177 if self.config.timed_challenge
179 && self.current_view == ContentView::Exercise
180 && self.timer_start.is_none()
181 {
182 self.timer_start = Some(std::time::Instant::now());
183 }
184 self.input.insert(self.cursor_pos, c);
185 self.cursor_pos += c.len_utf8();
186 self.history_idx = None;
188 }
189
190 pub fn input_backspace(&mut self) {
191 if self.cursor_pos > 0 {
192 let prev = self.input[..self.cursor_pos]
193 .char_indices()
194 .last()
195 .map(|(i, _)| i)
196 .unwrap_or(0);
197 self.input.remove(prev);
198 self.cursor_pos = prev;
199 }
200 self.history_idx = None;
201 }
202
203 pub fn input_delete(&mut self) {
204 if self.cursor_pos < self.input.len() {
205 self.input.remove(self.cursor_pos);
206 }
207 self.history_idx = None;
208 }
209
210 pub fn input_paste(&mut self, s: &str) {
212 for c in s.chars() {
213 if !c.is_control() {
214 self.input.insert(self.cursor_pos, c);
215 self.cursor_pos += c.len_utf8();
216 }
217 }
218 self.history_idx = None;
219 }
220
221 pub fn cursor_left(&mut self) {
222 if self.cursor_pos > 0 {
223 self.cursor_pos = self.input[..self.cursor_pos]
224 .char_indices()
225 .last()
226 .map(|(i, _)| i)
227 .unwrap_or(0);
228 }
229 }
230
231 pub fn cursor_right(&mut self) {
232 if self.cursor_pos < self.input.len() {
233 let mut chars = self.input[self.cursor_pos..].char_indices();
234 if let Some((_, c)) = chars.next() {
235 self.cursor_pos += c.len_utf8();
236 }
237 }
238 }
239
240 pub fn cursor_word_left(&mut self) {
242 let mut pos = self.cursor_pos;
243 while pos > 0 {
245 let c = self.input[..pos].chars().last().unwrap();
246 if c.is_alphanumeric() || c == '_' {
247 break;
248 }
249 pos -= c.len_utf8();
250 }
251 while pos > 0 {
253 let c = self.input[..pos].chars().last().unwrap();
254 if !c.is_alphanumeric() && c != '_' {
255 break;
256 }
257 pos -= c.len_utf8();
258 }
259 self.cursor_pos = pos;
260 }
261
262 pub fn cursor_word_right(&mut self) {
264 let mut pos = self.cursor_pos;
265 let len = self.input.len();
266 while pos < len {
268 let c = self.input[pos..].chars().next().unwrap();
269 if c.is_alphanumeric() || c == '_' {
270 break;
271 }
272 pos += c.len_utf8();
273 }
274 while pos < len {
276 let c = self.input[pos..].chars().next().unwrap();
277 if !c.is_alphanumeric() && c != '_' {
278 break;
279 }
280 pos += c.len_utf8();
281 }
282 self.cursor_pos = pos;
283 }
284
285 pub fn cursor_home(&mut self) {
286 self.cursor_pos = 0;
287 }
288
289 pub fn cursor_end(&mut self) {
290 self.cursor_pos = self.input.len();
291 }
292
293 pub fn clear_input(&mut self) {
294 self.input.clear();
295 self.cursor_pos = 0;
296 self.history_idx = None;
297 }
298
299 fn push_history(&mut self, cmd: &str) {
303 let cmd = cmd.trim().to_string();
304 if cmd.is_empty() {
305 return;
306 }
307 if self.command_history.last().map(|s| s == &cmd).unwrap_or(false) {
308 return;
309 }
310 self.command_history.push(cmd);
311 self.history_idx = None;
312 self.history_draft.clear();
313 }
314
315 pub fn history_prev(&mut self) {
317 if self.command_history.is_empty() {
318 return;
319 }
320 match self.history_idx {
321 None => {
322 self.history_draft = self.input.clone();
323 self.history_idx = Some(self.command_history.len() - 1);
324 }
325 Some(0) => return,
326 Some(i) => {
327 self.history_idx = Some(i - 1);
328 }
329 }
330 if let Some(i) = self.history_idx {
331 self.input = self.command_history[i].clone();
332 self.cursor_pos = self.input.len();
333 }
334 }
335
336 pub fn history_next(&mut self) {
338 match self.history_idx {
339 None => {}
340 Some(i) if i + 1 >= self.command_history.len() => {
341 self.history_idx = None;
342 self.input = self.history_draft.clone();
343 self.cursor_pos = self.input.len();
344 }
345 Some(i) => {
346 self.history_idx = Some(i + 1);
347 self.input = self.command_history[i + 1].clone();
348 self.cursor_pos = self.input.len();
349 }
350 }
351 }
352
353 pub fn activate_search(&mut self) {
357 self.search_active = true;
358 self.search_query.clear();
359 self.search_filtered = (0..self.modules.len()).collect();
360 }
361
362 pub fn search_push(&mut self, c: char) {
364 self.search_query.push(c);
365 self.update_search_filtered();
366 if let Some(&first) = self.search_filtered.first() {
367 self.selected_module = first;
368 }
369 }
370
371 pub fn search_backspace(&mut self) {
372 self.search_query.pop();
373 self.update_search_filtered();
374 if let Some(&first) = self.search_filtered.first() {
375 self.selected_module = first;
376 }
377 }
378
379 pub fn search_confirm(&mut self) {
381 if let Some(&first) = self.search_filtered.first() {
382 self.selected_module = first;
383 self.reset_content_state();
384 }
385 self.search_active = false;
386 self.search_query.clear();
387 }
388
389 pub fn search_cancel(&mut self) {
391 self.search_active = false;
392 self.search_query.clear();
393 self.search_filtered.clear();
394 }
395
396 fn update_search_filtered(&mut self) {
397 let q = self.search_query.to_lowercase();
398 self.search_filtered = self
399 .modules
400 .iter()
401 .enumerate()
402 .filter(|(_, m)| m.module.name.to_lowercase().contains(&q))
403 .map(|(i, _)| i)
404 .collect();
405 }
406
407 pub fn select_prev_module(&mut self) {
410 if self.selected_module > 0 {
411 self.selected_module -= 1;
412 self.reset_content_state();
413 }
414 }
415
416 pub fn select_next_module(&mut self) {
417 if self.selected_module + 1 < self.modules.len() {
418 self.selected_module += 1;
419 self.reset_content_state();
420 }
421 }
422
423 pub fn cycle_view(&mut self) {
425 self.current_view = match self.current_view {
426 ContentView::Intro => ContentView::Examples,
427 ContentView::Examples => ContentView::Exercise,
428 ContentView::Exercise => ContentView::FreePractice,
429 ContentView::FreePractice => ContentView::Intro,
430 };
431 }
432
433 pub fn cycle_difficulty_filter(&mut self) {
435 self.difficulty_filter = match self.difficulty_filter {
436 DifficultyFilter::None => DifficultyFilter::Beginner,
437 DifficultyFilter::Beginner => DifficultyFilter::Intermediate,
438 DifficultyFilter::Intermediate => DifficultyFilter::Advanced,
439 DifficultyFilter::Advanced => DifficultyFilter::None,
440 };
441 if self.current_view == ContentView::Exercise {
443 self.jump_to_first_matching_exercise();
444 }
445 }
446
447 pub fn next_exercise(&mut self) {
449 let count = self.exercise_count();
450 if count == 0 {
451 return;
452 }
453 let mut candidate = self.current_exercise;
454 loop {
455 if candidate + 1 >= count {
456 break;
457 }
458 candidate += 1;
459 if self.exercise_matches_filter(candidate) {
460 self.current_exercise = candidate;
461 self.reset_exercise_state();
462 return;
463 }
464 }
465 }
466
467 pub fn prev_exercise(&mut self) {
468 if self.current_exercise == 0 {
469 return;
470 }
471 let mut candidate = self.current_exercise;
472 loop {
473 if candidate == 0 {
474 break;
475 }
476 candidate -= 1;
477 if self.exercise_matches_filter(candidate) {
478 self.current_exercise = candidate;
479 self.reset_exercise_state();
480 return;
481 }
482 }
483 }
484
485 fn exercise_matches_filter(&self, idx: usize) -> bool {
486 let ex = match self.current_module().exercises.get(idx) {
487 Some(e) => e,
488 None => return false,
489 };
490 if self.config.skip_completed {
492 let module_name = &self.current_module().module.name;
493 if self.progress.is_completed(module_name, &ex.id) {
494 return false;
495 }
496 }
497 match self.difficulty_filter {
498 DifficultyFilter::None => true,
499 DifficultyFilter::Beginner => ex.difficulty == Difficulty::Beginner,
500 DifficultyFilter::Intermediate => ex.difficulty == Difficulty::Intermediate,
501 DifficultyFilter::Advanced => ex.difficulty == Difficulty::Advanced,
502 }
503 }
504
505 fn jump_to_first_matching_exercise(&mut self) {
506 let count = self.exercise_count();
507 for i in 0..count {
508 if self.exercise_matches_filter(i) {
509 self.current_exercise = i;
510 return;
511 }
512 }
513 }
514
515 pub fn submit_command(&mut self) {
518 let command = self.input.clone();
519 if command.trim().is_empty() {
520 return;
521 }
522
523 self.push_history(&command);
525
526 let exercise = match self.current_exercise_opt() {
527 Some(ex) => ex.clone(),
528 None => return,
529 };
530
531 let result = Executor::run(&command, &exercise.fixtures);
532 let (exec_result, exec_error) = match result {
533 Ok(o) => (Some(o), false),
534 Err(e) => (
535 Some(ExecutionResult {
536 stdout: String::new(),
537 stderr: e.to_string(),
538 timed_out: false,
539 }),
540 true,
541 ),
542 };
543
544 let correct = exec_result
545 .as_ref()
546 .map(|o| {
547 !exec_error
548 && Matcher::check(&o.stdout, &exercise.expected_output, &exercise.match_mode)
549 })
550 .unwrap_or(false);
551
552 self.last_output = exec_result;
553 self.output_scroll = 0;
554
555 let module_name = self.current_module().module.name.clone();
556 if exec_error {
557 self.submit_state = SubmitState::Error;
558 } else if correct {
559 self.submit_state = SubmitState::Correct;
560 let already_completed = self.progress.is_completed(&module_name, &exercise.id);
562 self.progress.mark_completed(&module_name, &exercise.id);
563
564 if self.config.timed_challenge {
566 if let Some(start) = self.timer_start.take() {
567 let ms = start.elapsed().as_millis() as u64;
568 self.last_solve_ms = Some(ms);
569 self.progress.record_time(&module_name, &exercise.id, ms);
570 }
571 }
572
573 if !already_completed {
575 let xp: u64 = match exercise.difficulty {
576 Difficulty::Beginner => 10,
577 Difficulty::Intermediate => 20,
578 Difficulty::Advanced => 30,
579 };
580 self.stats.add_xp(xp);
581 self.stats.update_streak();
582 let _ = self.stats.save();
583 }
584
585 let _ = self.progress.save();
586 } else {
587 self.submit_state = SubmitState::Wrong;
588 self.progress.mark_attempted(&module_name, &exercise.id);
589 }
590 }
591
592 pub fn submit_command_free(&mut self) {
594 let command = self.input.clone();
595 if command.trim().is_empty() {
596 return;
597 }
598 self.push_history(&command);
599
600 let result = Executor::run(&command, &[]);
601 self.last_output = match result {
602 Ok(o) => Some(o),
603 Err(e) => Some(ExecutionResult {
604 stdout: String::new(),
605 stderr: e.to_string(),
606 timed_out: false,
607 }),
608 };
609 self.submit_state = SubmitState::Idle;
610 self.output_scroll = 0;
611 }
612
613 pub fn reveal_next_hint(&mut self) {
614 if let Some(ex) = self.current_exercise_opt() {
615 if self.hints_revealed < ex.hints.len() {
616 self.hints_revealed += 1;
617 }
618 }
619 }
620
621 pub fn toggle_solution(&mut self) {
622 self.show_solution = !self.show_solution;
623 }
624
625 pub fn toggle_files(&mut self) {
626 self.show_files = !self.show_files;
627 }
628
629 pub fn toggle_help(&mut self) {
630 self.show_help = !self.show_help;
631 }
632
633 pub fn toggle_progress(&mut self) {
635 self.show_progress = !self.show_progress;
636 }
637
638 pub fn reset_exercise(&mut self) {
639 self.reset_exercise_state();
640 }
641
642 pub fn clear_output(&mut self) {
643 self.last_output = None;
644 self.submit_state = SubmitState::Idle;
645 self.output_scroll = 0;
646 }
647
648 pub fn scroll_up(&mut self) {
651 match self.current_view {
652 ContentView::Intro => self.intro_scroll = self.intro_scroll.saturating_sub(1),
653 ContentView::Examples => {
654 self.examples_scroll = self.examples_scroll.saturating_sub(1)
655 }
656 ContentView::Exercise | ContentView::FreePractice => {
657 self.output_scroll = self.output_scroll.saturating_sub(1)
658 }
659 }
660 }
661
662 pub fn scroll_down(&mut self) {
663 match self.current_view {
664 ContentView::Intro => self.intro_scroll += 1,
665 ContentView::Examples => self.examples_scroll += 1,
666 ContentView::Exercise | ContentView::FreePractice => self.output_scroll += 1,
667 }
668 }
669
670 fn reset_content_state(&mut self) {
673 self.current_view = ContentView::Intro;
674 self.current_exercise = 0;
675 self.reset_exercise_state();
676 self.intro_scroll = 0;
677 self.examples_scroll = 0;
678 self.difficulty_filter = DifficultyFilter::None;
680 }
681
682 fn reset_exercise_state(&mut self) {
683 self.clear_input();
684 self.last_output = None;
685 self.submit_state = SubmitState::Idle;
686 self.hints_revealed = 0;
687 self.show_solution = false;
688 self.show_files = false;
689 self.output_scroll = 0;
690 self.timer_start = None;
692 self.last_solve_ms = None;
693 }
694}