cli_tutor/ui/
exercise_view.rs1use 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
12pub 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 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 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 } else {
57 constraints.push(Constraint::Length(1)); 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)); constraints.push(Constraint::Min(4)); let chunks = Layout::default()
71 .direction(Direction::Vertical)
72 .constraints(constraints)
73 .split(area);
74
75 let mut chunk_idx = 0;
76
77 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 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 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 input_bar::render(app, frame, chunks[chunk_idx]);
158 chunk_idx += 1;
159
160 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}