Skip to main content

logicaffeine_web/ui/components/
guide_code_block.rs

1//! Interactive code block component for the Programmer's Guide.
2//!
3//! Features:
4//! - Editable code area
5//! - Run button (Logic mode: FOL output, Imperative mode: interpreter)
6//! - Copy button
7//! - Reset button
8//! - Output panel
9
10use dioxus::prelude::*;
11use crate::ui::pages::guide::content::ExampleMode;
12use logicaffeine_compile::{compile_for_ui, interpret_for_ui};
13
14const CODE_BLOCK_STYLE: &str = r#"
15.guide-code-block {
16    border-radius: 16px;
17    border: 1px solid rgba(255,255,255,0.10);
18    background: rgba(0,0,0,0.35);
19    overflow: hidden;
20    margin: 20px 0;
21    box-shadow: 0 8px 32px rgba(0,0,0,0.3);
22}
23
24.guide-code-header {
25    display: flex;
26    justify-content: space-between;
27    align-items: center;
28    padding: 12px 16px;
29    background: rgba(255,255,255,0.04);
30    border-bottom: 1px solid rgba(255,255,255,0.08);
31}
32
33.guide-code-label {
34    display: flex;
35    align-items: center;
36    gap: 10px;
37}
38
39.guide-code-title {
40    font-size: 13px;
41    font-weight: 600;
42    color: rgba(229,231,235,0.9);
43}
44
45.guide-code-mode {
46    font-size: 11px;
47    padding: 4px 10px;
48    border-radius: 999px;
49    font-weight: 600;
50    text-transform: uppercase;
51    letter-spacing: 0.5px;
52}
53
54.guide-code-mode.logic {
55    background: rgba(167,139,250,0.2);
56    color: #a78bfa;
57    border: 1px solid rgba(167,139,250,0.3);
58}
59
60.guide-code-mode.imperative {
61    background: rgba(34,197,94,0.2);
62    color: #22c55e;
63    border: 1px solid rgba(34,197,94,0.3);
64}
65
66.guide-code-actions {
67    display: flex;
68    gap: 8px;
69}
70
71.guide-code-btn {
72    padding: 8px 14px;
73    border-radius: 8px;
74    border: 1px solid rgba(255,255,255,0.12);
75    background: rgba(255,255,255,0.06);
76    color: rgba(229,231,235,0.8);
77    font-size: 12px;
78    font-weight: 600;
79    cursor: pointer;
80    transition: all 0.2s ease;
81    display: flex;
82    align-items: center;
83    gap: 6px;
84}
85
86.guide-code-btn:hover {
87    background: rgba(255,255,255,0.10);
88    border-color: rgba(255,255,255,0.20);
89    color: #fff;
90}
91
92.guide-code-btn:active {
93    transform: scale(0.97);
94}
95
96.guide-code-btn.primary {
97    background: linear-gradient(135deg, rgba(96,165,250,0.9), rgba(167,139,250,0.9));
98    border-color: rgba(255,255,255,0.2);
99    color: #060814;
100}
101
102.guide-code-btn.primary:hover {
103    background: linear-gradient(135deg, #60a5fa, #a78bfa);
104}
105
106.guide-code-btn.running {
107    opacity: 0.7;
108    cursor: wait;
109}
110
111.guide-code-editor {
112    position: relative;
113}
114
115.guide-code-textarea {
116    width: 100%;
117    min-height: 80px;
118    padding: 16px;
119    background: transparent;
120    border: none;
121    color: rgba(229,231,235,0.95);
122    font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace;
123    font-size: 14px;
124    line-height: 1.7;
125    resize: vertical;
126    outline: none;
127    tab-size: 4;
128}
129
130.guide-code-textarea::placeholder {
131    color: rgba(229,231,235,0.3);
132}
133
134.guide-code-output {
135    border-top: 1px solid rgba(255,255,255,0.08);
136    background: rgba(0,0,0,0.25);
137}
138
139.guide-code-output-header {
140    display: flex;
141    align-items: center;
142    justify-content: space-between;
143    padding: 10px 16px;
144    font-size: 11px;
145    font-weight: 600;
146    text-transform: uppercase;
147    letter-spacing: 0.5px;
148    color: rgba(229,231,235,0.5);
149    border-bottom: 1px solid rgba(255,255,255,0.06);
150}
151
152.guide-code-output-content {
153    padding: 16px;
154    font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, monospace;
155    font-size: 14px;
156    line-height: 1.6;
157    white-space: pre-wrap;
158    word-break: break-word;
159}
160
161.guide-code-output-content.success {
162    color: #a78bfa;
163}
164
165.guide-code-output-content.error {
166    color: #f87171;
167}
168
169.guide-code-output-content.info {
170    color: rgba(229,231,235,0.7);
171}
172
173.guide-code-copied {
174    position: absolute;
175    top: 50%;
176    left: 50%;
177    transform: translate(-50%, -50%);
178    padding: 8px 16px;
179    background: rgba(34,197,94,0.9);
180    color: #fff;
181    border-radius: 8px;
182    font-size: 13px;
183    font-weight: 600;
184    animation: fadeInOut 1.5s ease forwards;
185    pointer-events: none;
186}
187
188@keyframes fadeInOut {
189    0% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
190    15% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
191    85% { opacity: 1; }
192    100% { opacity: 0; }
193}
194
195.guide-code-placeholder {
196    padding: 24px 16px;
197    text-align: center;
198    color: rgba(229,231,235,0.4);
199    font-size: 13px;
200}
201"#;
202
203#[derive(Props, Clone, PartialEq)]
204pub struct GuideCodeBlockProps {
205    pub id: String,
206    pub label: String,
207    pub mode: ExampleMode,
208    pub initial_code: String,
209}
210
211#[component]
212pub fn GuideCodeBlock(props: GuideCodeBlockProps) -> Element {
213    let mut code = use_signal(|| props.initial_code.clone());
214    let mut output = use_signal(String::new);
215    let mut output_type = use_signal(|| "info".to_string());
216    let mut is_running = use_signal(|| false);
217    let mut show_copied = use_signal(|| false);
218    let mut has_run = use_signal(|| false);
219
220    let initial_code = props.initial_code.clone();
221    let mode = props.mode;
222    let id = props.id.clone();
223
224    // Run handler
225    let handle_run = move |_| {
226        is_running.set(true);
227        has_run.set(true);
228
229        let current_code = code.read().clone();
230
231        match mode {
232            ExampleMode::Logic => {
233                // Use compile_for_ui for Logic mode
234                let result = compile_for_ui(&current_code);
235                if let Some(logic) = result.logic {
236                    output.set(logic);
237                    output_type.set("success".to_string());
238                } else if let Some(err) = result.error {
239                    output.set(err);
240                    output_type.set("error".to_string());
241                } else {
242                    output.set("No output".to_string());
243                    output_type.set("info".to_string());
244                }
245            }
246            ExampleMode::Imperative => {
247                // Phase 55: interpret_for_ui is now async for VFS support
248                spawn(async move {
249                    let result = interpret_for_ui(&current_code).await;
250                    if let Some(err) = result.error {
251                        output.set(err);
252                        output_type.set("error".to_string());
253                    } else if result.lines.is_empty() {
254                        output.set("(no output)".to_string());
255                        output_type.set("info".to_string());
256                    } else {
257                        output.set(result.lines.join("\n"));
258                        output_type.set("success".to_string());
259                    }
260                    is_running.set(false);
261                });
262                return;
263            }
264        }
265
266        is_running.set(false);
267    };
268
269    // Copy handler
270    let handle_copy = move |_| {
271        let code_to_copy = code.read().clone();
272
273        #[cfg(target_arch = "wasm32")]
274        {
275            if let Some(window) = web_sys::window() {
276                let clipboard = window.navigator().clipboard();
277                let _ = clipboard.write_text(&code_to_copy);
278                show_copied.set(true);
279
280                // Reset after animation
281                spawn(async move {
282                    gloo_timers::future::TimeoutFuture::new(1500).await;
283                    show_copied.set(false);
284                });
285            }
286        }
287
288        #[cfg(not(target_arch = "wasm32"))]
289        {
290            let _ = code_to_copy;
291            show_copied.set(true);
292        }
293    };
294
295    // Reset handler
296    let handle_reset = {
297        let initial = initial_code.clone();
298        move |_| {
299            code.set(initial.clone());
300            output.set(String::new());
301            has_run.set(false);
302        }
303    };
304
305    let mode_class = match mode {
306        ExampleMode::Logic => "logic",
307        ExampleMode::Imperative => "imperative",
308    };
309
310    let mode_label = match mode {
311        ExampleMode::Logic => "Logic",
312        ExampleMode::Imperative => "Run",
313    };
314
315    // Calculate rows based on code line count for textarea auto-sizing
316    let line_count = code.read().lines().count();
317    let rows = (line_count + 2).max(4) as i64; // minimum 4 rows, +2 for editing room
318
319    rsx! {
320        style { "{CODE_BLOCK_STYLE}" }
321
322        div {
323            class: "guide-code-block",
324            id: "{id}",
325
326            // Header
327            div { class: "guide-code-header",
328                div { class: "guide-code-label",
329                    span { class: "guide-code-title", "{props.label}" }
330                    span { class: "guide-code-mode {mode_class}",
331                        match mode {
332                            ExampleMode::Logic => "Logic Mode",
333                            ExampleMode::Imperative => "Imperative",
334                        }
335                    }
336                }
337
338                div { class: "guide-code-actions",
339                    button {
340                        class: if *is_running.read() { "guide-code-btn primary running" } else { "guide-code-btn primary" },
341                        onclick: handle_run,
342                        disabled: *is_running.read(),
343                        if *is_running.read() {
344                            "Running..."
345                        } else {
346                            "{mode_label}"
347                        }
348                    }
349                    button {
350                        class: "guide-code-btn",
351                        onclick: handle_copy,
352                        "Copy"
353                    }
354                    button {
355                        class: "guide-code-btn",
356                        onclick: handle_reset,
357                        "Reset"
358                    }
359                }
360            }
361
362            // Editor
363            div { class: "guide-code-editor",
364                textarea {
365                    class: "guide-code-textarea",
366                    rows: rows,
367                    value: "{code}",
368                    oninput: move |evt| code.set(evt.value()),
369                    spellcheck: "false",
370                    autocomplete: "off",
371                    autocapitalize: "off",
372                }
373
374                if *show_copied.read() {
375                    div { class: "guide-code-copied", "Copied!" }
376                }
377            }
378
379            // Output (only show if has run)
380            if *has_run.read() {
381                div { class: "guide-code-output",
382                    div { class: "guide-code-output-header",
383                        span { "Output" }
384                    }
385                    div {
386                        class: "guide-code-output-content {output_type}",
387                        "{output}"
388                    }
389                }
390            }
391        }
392    }
393}