logicaffeine_web/ui/components/
exercise_block.rs1use super::inline_editor::{InlineEditor, InlineEditorStyle};
16use crate::grader::check_answer;
17use crate::guide_review::record_exercise_result;
18use crate::progress::UserProgress;
19use crate::ui::pages::guide::content::{ExampleMode, ExerciseKind};
20use dioxus::prelude::*;
21use logicaffeine_compile::interpret_for_ui;
22
23#[derive(Props, Clone, PartialEq)]
24pub struct ExerciseBlockProps {
25 pub id: String,
26 pub prompt: String,
27 pub mode: ExampleMode,
28 pub expected: String,
29 pub starter: String,
30 pub kind: ExerciseKind,
31 pub concepts: Vec<String>,
32 pub hints: Vec<String>,
33}
34
35fn record_grade(progress_ctx: Option<Signal<UserProgress>>, exercise_id: &str, quality: u8) {
36 let today = crate::ui::pages::guide::today_string();
37 match progress_ctx {
38 Some(mut progress) => {
39 record_exercise_result(&mut progress.write(), exercise_id, quality, &today);
40 }
41 None => {
42 let mut progress = UserProgress::load();
43 record_exercise_result(&mut progress, exercise_id, quality, &today);
44 }
45 }
46}
47
48#[component]
49pub fn ExerciseBlock(props: ExerciseBlockProps) -> Element {
50 let mut attempt = use_signal(|| props.starter.clone());
51 let mut feedback = use_signal(String::new);
52 let mut passed = use_signal(|| false);
53 let mut hints_shown = use_signal(|| 0usize);
54 let mut fail_recorded = use_signal(|| false);
55
56 let progress_ctx = try_consume_context::<Signal<UserProgress>>();
57
58 let mode = props.mode;
59 let expected = props.expected.clone();
60 let exercise_id = props.id.clone();
61
62 let mut on_graded = move |correct: bool| {
67 if correct {
68 let quality = if *hints_shown.read() > 0 { 4 } else { 5 };
69 record_grade(progress_ctx, &exercise_id, quality);
70 } else if !*fail_recorded.read() {
71 fail_recorded.set(true);
72 record_grade(progress_ctx, &exercise_id, 1);
73 }
74 };
75
76 let handle_check = move |_| {
77 let current = attempt.read().clone();
78 match mode {
79 ExampleMode::Theorem => {
80 feedback.set("Theorem exercises are proved with largo prove.".to_string());
83 }
84 ExampleMode::Logic => {
85 let grade = check_answer(¤t, &expected);
86 passed.set(grade.correct);
87 feedback.set(if grade.correct {
88 "✓ Correct!".to_string()
89 } else {
90 format!("Not yet — {}", grade.feedback)
91 });
92 on_graded(grade.correct);
93 }
94 ExampleMode::Web => {
95 #[cfg(not(target_arch = "wasm32"))]
96 {
97 let verdict = crate::ui::pages::guide::page_verdict(¤t);
98 let correct = verdict.is_ok();
99 passed.set(correct);
100 feedback.set(match verdict {
101 Ok(n) => format!("✓ Correct — {n} test(s) pass."),
102 Err(why) => format!("Not yet — {}", why.lines().next().unwrap_or("")),
103 });
104 on_graded(correct);
105 }
106 #[cfg(target_arch = "wasm32")]
109 {
110 let _ = ¤t;
111 feedback.set(
112 "A page is graded by running its `## Test` blocks, and the test runner is \
113 not part of the in-browser guide. Save the page in a project and run \
114 `largo test`."
115 .to_string(),
116 );
117 }
118 }
119 ExampleMode::Imperative => {
120 let expected = expected.clone();
121 let mut on_graded = on_graded.clone();
122 spawn(async move {
123 let r = interpret_for_ui(¤t).await;
124 match r.error {
125 Some(e) => {
126 passed.set(false);
127 feedback.set(format!(
128 "Your program errored: {}",
129 e.lines().next().unwrap_or("")
130 ));
131 on_graded(false);
132 }
133 None => {
134 let out = r.lines.join("\n");
135 if out == expected {
136 passed.set(true);
137 feedback.set("✓ Correct!".to_string());
138 on_graded(true);
139 } else {
140 passed.set(false);
141 feedback.set(format!("Not yet — your output was:\n{out}"));
142 on_graded(false);
143 }
144 }
145 }
146 });
147 }
148 }
149 };
150
151 let rows = attempt.read().lines().count().max(3) + 1;
152 let kind_label = match props.kind {
153 ExerciseKind::Completion => "Fill in the blank",
154 ExerciseKind::Practice => "Practice",
155 ExerciseKind::Challenge => "Challenge",
156 };
157 let shown = *hints_shown.read();
158 let total_hints = props.hints.len();
159
160 rsx! {
161 if mode != ExampleMode::Logic {
162 InlineEditorStyle {}
163 }
164 div {
165 class: "guide-exercise",
166 id: "{props.id}",
167 style: "border: 1px solid rgba(96,165,250,0.25); border-radius: 12px; margin: 20px 0; background: rgba(96,165,250,0.05); overflow: hidden;",
168
169 div {
170 style: "padding: 12px 16px; font-size: 13px; font-weight: 600; color: rgba(229,231,235,0.9); border-bottom: 1px solid rgba(255,255,255,0.08);",
171 span { style: "color: #60a5fa; margin-right: 8px;", "Now you try" }
172 span {
173 class: "guide-exercise-kind",
174 style: "display: inline-block; margin-right: 8px; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; background: rgba(96,165,250,0.15); color: #60a5fa; border: 1px solid rgba(96,165,250,0.3);",
175 "{kind_label}"
176 }
177 "{props.prompt}"
178 if !props.concepts.is_empty() {
179 div {
180 class: "guide-exercise-concepts",
181 style: "margin-top: 6px; font-size: 11px; font-weight: 500; color: rgba(229,231,235,0.45);",
182 "practices: "
183 {props.concepts.join(" · ")}
184 }
185 }
186 }
187 if mode == ExampleMode::Logic {
188 textarea {
189 style: "width: 100%; padding: 14px 16px; background: transparent; border: none; color: rgba(229,231,235,0.95); font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.6; resize: vertical; outline: none;",
190 rows: rows as i64,
191 value: "{attempt}",
192 oninput: move |evt| attempt.set(evt.value()),
193 spellcheck: "false",
194 }
195 } else {
196 InlineEditor {
197 value: attempt.read().clone(),
198 on_change: move |v| attempt.set(v),
199 language: mode.language(),
200 }
201 }
202 if shown > 0 {
204 div {
205 class: "guide-exercise-hints",
206 style: "padding: 8px 16px; border-top: 1px solid rgba(255,255,255,0.06); display: flex; flex-direction: column; gap: 6px;",
207 for hint in props.hints.iter().take(shown) {
208 div {
209 style: "font-size: 12px; color: #fbbf24; line-height: 1.5;",
210 "💡 {hint}"
211 }
212 }
213 }
214 }
215 div {
216 style: "display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-top: 1px solid rgba(255,255,255,0.06);",
217 button {
218 class: "guide-exercise-check",
219 style: "padding: 7px 14px; border-radius: 8px; border: none; background: linear-gradient(135deg, rgba(96,165,250,0.9), rgba(167,139,250,0.9)); color: #060814; font-size: 12px; font-weight: 700; cursor: pointer;",
220 onclick: handle_check,
221 "Check"
222 }
223 if total_hints > 0 && shown < total_hints {
224 button {
225 class: "guide-exercise-hint",
226 style: "padding: 7px 14px; border-radius: 8px; border: 1px solid rgba(251,191,36,0.4); background: none; color: #fbbf24; font-size: 12px; font-weight: 600; cursor: pointer;",
227 onclick: move |_| { let n = *hints_shown.read(); hints_shown.set(n + 1); },
228 if shown == 0 { "Hint ({total_hints})" } else { "Another hint ({shown}/{total_hints})" }
229 }
230 }
231 if !feedback.read().is_empty() {
232 span {
233 style: if *passed.read() { "color: #22c55e; font-size: 13px; white-space: pre-wrap;" } else { "color: #fbbf24; font-size: 13px; white-space: pre-wrap;" },
234 "{feedback}"
235 }
236 }
237 }
238 }
239 }
240}