Skip to main content

cli_tutor/ui/
exercise_view.rs

1use crate::app::{App, SubmitState};
2use crate::content::types::Difficulty;
3use crate::ui::input_bar;
4use ratatui::{
5    layout::{Constraint, Direction, Layout, Rect},
6    style::{Color, Modifier, Style},
7    text::{Line, Span},
8    widgets::{Block, Borders, Paragraph, Wrap},
9    Frame,
10};
11
12// content_display.EXERCISE_VIEW — all requirements annotated inline
13pub fn render(app: &App, frame: &mut Frame, area: Rect) {
14    let exercise = match app.current_exercise_opt() {
15        Some(ex) => ex,
16        None => {
17            frame.render_widget(
18                Paragraph::new("No exercises in this module.")
19                    .block(Block::default().borders(Borders::ALL).title("Exercise")),
20                area,
21            );
22            return;
23        }
24    };
25
26    let module_name = &app.current_module().module.name;
27    let ex_count = app.exercise_count();
28
29    // content_display.EXERCISE_VIEW.6 — difficulty badge
30    let (diff_label, diff_color) = match exercise.difficulty {
31        Difficulty::Beginner => ("Beginner", Color::Green),
32        Difficulty::Intermediate => ("Intermediate", Color::Yellow),
33        Difficulty::Advanced => ("Advanced", Color::Red),
34    };
35
36    let done_mark = if app.exercise_is_completed() {
37        " ✓"
38    } else {
39        ""
40    };
41    let title = format!(
42        " Exercise {}/{}{} — {} ",
43        app.current_exercise + 1,
44        ex_count,
45        done_mark,
46        diff_label
47    );
48
49    // Layout: question | [files] | [hints] | input | output
50    let has_files = app.show_files && !exercise.fixtures.is_empty();
51    let has_hints = app.hints_revealed > 0;
52
53    let mut constraints = vec![Constraint::Length(question_height(&exercise.question))];
54    if exercise.fixtures.is_empty() {
55        // no file toggle row
56    } else {
57        constraints.push(Constraint::Length(1)); // files toggle hint
58        if has_files {
59            constraints.push(Constraint::Length(
60                (exercise.fixtures.len() * 3).min(12) as u16
61            ));
62        }
63    }
64    if has_hints {
65        constraints.push(Constraint::Length(app.hints_revealed as u16 + 2));
66    }
67    constraints.push(Constraint::Length(3)); // input bar
68    constraints.push(Constraint::Min(4)); // output
69
70    let chunks = Layout::default()
71        .direction(Direction::Vertical)
72        .constraints(constraints)
73        .split(area);
74
75    let mut chunk_idx = 0;
76
77    // content_display.EXERCISE_VIEW.1 — question
78    let question_block = Paragraph::new(exercise.question.as_str())
79        .block(
80            Block::default()
81                .borders(Borders::ALL)
82                .title(Line::from(vec![
83                    Span::raw(title),
84                    Span::styled(
85                        diff_label,
86                        Style::default().fg(diff_color).add_modifier(Modifier::BOLD),
87                    ),
88                ])),
89        )
90        .wrap(Wrap { trim: false });
91    frame.render_widget(question_block, chunks[chunk_idx]);
92    chunk_idx += 1;
93
94    // content_display.EXERCISE_VIEW.2 — files section
95    if !exercise.fixtures.is_empty() {
96        let toggle_hint = if app.show_files {
97            "f: hide files"
98        } else {
99            "f: show files"
100        };
101        frame.render_widget(
102            Paragraph::new(toggle_hint).style(Style::default().fg(Color::DarkGray)),
103            chunks[chunk_idx],
104        );
105        chunk_idx += 1;
106
107        if has_files {
108            let mut file_lines: Vec<Line<'static>> = Vec::new();
109            for fixture in &exercise.fixtures {
110                file_lines.push(Line::from(Span::styled(
111                    format!("── {} ──", fixture.filename),
112                    Style::default().fg(Color::Cyan),
113                )));
114                for (i, l) in fixture.content.lines().enumerate().take(20) {
115                    file_lines.push(Line::from(Span::styled(
116                        format!("{:>3}  {}", i + 1, l),
117                        Style::default().fg(Color::DarkGray),
118                    )));
119                }
120            }
121            frame.render_widget(
122                Paragraph::new(file_lines)
123                    .block(Block::default().borders(Borders::LEFT | Borders::RIGHT)),
124                chunks[chunk_idx],
125            );
126            chunk_idx += 1;
127        }
128    }
129
130    // content_display.EXERCISE_VIEW.7 — hints block
131    if has_hints {
132        let mut hint_lines: Vec<Line<'static>> = Vec::new();
133        for i in 0..app.hints_revealed {
134            if let Some(hint) = exercise.hints.get(i) {
135                hint_lines.push(Line::from(Span::styled(
136                    format!("Hint {}: {}", i + 1, hint),
137                    Style::default().fg(Color::Cyan),
138                )));
139            }
140        }
141        if app.show_solution {
142            hint_lines.push(Line::from(Span::styled(
143                format!("Solution: {}", exercise.solution),
144                Style::default()
145                    .fg(Color::Yellow)
146                    .add_modifier(Modifier::BOLD),
147            )));
148        }
149        frame.render_widget(
150            Paragraph::new(hint_lines).block(Block::default().borders(Borders::ALL).title("Hints")),
151            chunks[chunk_idx],
152        );
153        chunk_idx += 1;
154    }
155
156    // content_display.EXERCISE_VIEW.3 — input bar
157    input_bar::render(app, frame, chunks[chunk_idx]);
158    chunk_idx += 1;
159
160    // content_display.EXERCISE_VIEW.4,5 — output panel, scrollable
161    let (output_text, output_style) = match app.submit_state {
162        SubmitState::Correct => (
163            "✓ Correct!\n".to_string()
164                + app
165                    .last_output
166                    .as_ref()
167                    .map(|o| o.stdout.as_str())
168                    .unwrap_or(""),
169            Style::default().fg(Color::Green),
170        ),
171        SubmitState::Wrong => {
172            let out = app.last_output.as_ref();
173            let stdout = out.map(|o| o.stdout.as_str()).unwrap_or("");
174            let stderr = out.map(|o| o.stderr.as_str()).unwrap_or("");
175            let timed_out = out.map(|o| o.timed_out).unwrap_or(false);
176            let text = if timed_out {
177                "✗ Command timed out after 3s".to_string()
178            } else if !stderr.is_empty() {
179                format!("✗ Wrong\nstderr: {stderr}")
180            } else {
181                format!("✗ Wrong\nGot:\n{stdout}")
182            };
183            (text, Style::default().fg(Color::Red))
184        }
185        SubmitState::Error => (
186            app.last_output
187                .as_ref()
188                .map(|o| format!("Error: {}", o.stderr))
189                .unwrap_or_else(|| "Error".to_string()),
190            Style::default().fg(Color::Yellow),
191        ),
192        SubmitState::Idle => {
193            let text = app
194                .last_output
195                .as_ref()
196                .map(|o| o.stdout.clone())
197                .unwrap_or_default();
198            (text, Style::default())
199        }
200    };
201
202    let output_para = Paragraph::new(output_text)
203        .block(Block::default().borders(Borders::ALL).title("Output"))
204        .style(output_style)
205        .wrap(Wrap { trim: false })
206        .scroll((app.output_scroll, 0));
207    frame.render_widget(output_para, chunks[chunk_idx]);
208
209    let nav_area = Rect {
210        x: area.x,
211        y: area.y + area.height.saturating_sub(1),
212        width: area.width,
213        height: 1,
214    };
215    let nav = format!(
216        " [← p] Exercise {}/{} [n →]   {} ",
217        app.current_exercise + 1,
218        ex_count,
219        module_name
220    );
221    frame.render_widget(
222        Paragraph::new(nav).style(Style::default().fg(Color::DarkGray)),
223        nav_area,
224    );
225}
226
227fn question_height(question: &str) -> u16 {
228    let lines = question.lines().count() as u16;
229    (lines + 2).clamp(4, 10)
230}