Skip to main content

logicaffeine_web/ui/components/
code_editor.rs

1//! Syntax-highlighted code editor component.
2//!
3//! Provides a textarea with real-time syntax highlighting overlay. Uses a
4//! transparent textarea layered over a highlighted `pre` element for editing.
5//!
6//! # Components
7//!
8//! - [`CodeEditor`] - Editable code editor with syntax highlighting
9//! - [`CodeView`] - Read-only syntax highlighted display
10//!
11//! # Languages
12//!
13//! - **Logos**: Imperative `.logos` files (zones, parallel, etc.)
14//! - **Vernacular**: Math/theorem mode (Definition, Check, Eval)
15//! - **Rust**: Generated Rust output
16//!
17//! # Props (CodeEditor)
18//!
19//! - `value` - Current code content
20//! - `on_change` - Callback when content changes
21//! - `language` - Syntax highlighting mode
22//! - `placeholder` - Optional placeholder text
23//! - `readonly` - Whether editing is disabled
24
25use dioxus::prelude::*;
26
27/// Language mode for syntax highlighting.
28#[derive(Clone, Copy, PartialEq, Eq, Default)]
29pub enum Language {
30    #[default]
31    Logos,      // Imperative .logos files
32    Vernacular, // Math/theorem mode (Definition, Check, Eval)
33    Rust,       // Generated Rust output
34}
35
36/// Token type for syntax highlighting.
37#[derive(Clone, Copy, PartialEq, Eq)]
38enum TokenKind {
39    Keyword,
40    Type,
41    String,
42    Number,
43    Comment,
44    Operator,
45    Punctuation,
46    Identifier,
47    Builtin,
48}
49
50/// A highlighted token with its text and kind.
51struct Token {
52    text: String,
53    kind: TokenKind,
54}
55
56const CODE_EDITOR_STYLE: &str = r#"
57.code-editor {
58    display: flex;
59    flex-direction: column;
60    height: 100%;
61    background: #0f1419;
62    font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
63}
64
65.code-editor-input {
66    position: relative;
67    flex: 1;
68    min-height: 200px;
69}
70
71.code-editor-textarea {
72    position: absolute;
73    top: 0;
74    left: 0;
75    width: 100%;
76    height: 100%;
77    padding: 16px;
78    padding-bottom: 50%;  /* Extra space to scroll past end */
79    margin: 0;
80    font-size: 14px;
81    font-family: inherit;
82    line-height: 1.6;
83    background: transparent;
84    color: transparent;
85    caret-color: #667eea;
86    border: none;
87    outline: none;
88    resize: none;
89    white-space: pre-wrap;
90    word-wrap: break-word;
91    overflow-wrap: break-word;
92    overflow: auto;
93    z-index: 2;
94    box-sizing: border-box;
95}
96
97.code-editor-highlight {
98    position: absolute;
99    top: 0;
100    left: 0;
101    width: 100%;
102    height: 100%;
103    padding: 16px;
104    padding-bottom: 50%;  /* Extra space to scroll past end */
105    margin: 0;
106    font-size: 14px;
107    font-family: inherit;
108    line-height: 1.6;
109    color: #e8eaed;
110    white-space: pre-wrap;
111    word-wrap: break-word;
112    overflow-wrap: break-word;
113    overflow: auto;
114    pointer-events: none;
115    z-index: 1;
116    box-sizing: border-box;
117    /* Hide scrollbar but keep scroll functionality for sync */
118    scrollbar-width: none;
119    -ms-overflow-style: none;
120}
121
122.code-editor-highlight::-webkit-scrollbar {
123    display: none;
124}
125
126/* Syntax highlighting colors - no font-weight/style changes to keep heights identical */
127.tok-keyword { color: #c678dd; }
128.tok-type { color: #e5c07b; }
129.tok-string { color: #98c379; }
130.tok-number { color: #d19a66; }
131.tok-comment { color: #5c6370; }
132.tok-operator { color: #56b6c2; }
133.tok-punctuation { color: #abb2bf; }
134.tok-identifier { color: #e8eaed; }
135.tok-builtin { color: #61afef; }
136
137/* Mobile optimizations */
138@media (max-width: 768px) {
139    .code-editor-textarea,
140    .code-editor-highlight {
141        font-size: 16px; /* Prevent iOS zoom */
142        padding: 12px;
143    }
144}
145"#;
146
147/// Tokenize source code for syntax highlighting.
148fn tokenize(source: &str, language: Language) -> Vec<Token> {
149    let mut tokens = Vec::new();
150    let chars: Vec<char> = source.chars().collect();
151    let len = chars.len();
152    let mut i = 0;
153
154    let keywords = match language {
155        Language::Logos => &[
156            "let", "set", "if", "then", "else", "while", "repeat", "for", "in",
157            "return", "match", "with", "end", "function", "struct", "enum",
158            "show", "read", "write", "push", "pop", "add", "remove",
159            "and", "or", "not", "true", "false", "nothing",
160            "zone", "concurrent", "parallel", "launch", "send", "receive", "select",
161            "mount", "sync", "merge", "increase", "decrease",
162        ][..],
163        Language::Vernacular => &[
164            "Definition", "Check", "Eval", "Inductive", "Theorem", "Lemma",
165            "Proof", "Qed", "Axiom", "Hypothesis", "Variable", "Parameter",
166            "fun", "forall", "exists", "match", "with", "end", "let", "in",
167            "Prop", "Type", "Set", "Nat", "Bool", "True", "False",
168            "Not", "And", "Or", "Eq", "Ex", "All",
169        ][..],
170        Language::Rust => &[
171            "fn", "let", "mut", "const", "static", "if", "else", "match", "loop",
172            "while", "for", "in", "return", "break", "continue", "struct", "enum",
173            "impl", "trait", "type", "pub", "mod", "use", "crate", "self", "super",
174            "async", "await", "move", "ref", "where", "true", "false",
175        ][..],
176    };
177
178    let types = match language {
179        Language::Logos => &[
180            "Int", "Text", "Bool", "Real", "Char", "Byte", "Nothing",
181            "Seq", "Map", "Set", "Option", "Result", "Tuple",
182            "Persistent", "Distributed",
183        ][..],
184        Language::Vernacular => &[
185            "Prop", "Type", "Set", "Nat", "Bool", "Int", "List", "Option",
186            "Syntax", "Derivation", "Term",
187        ][..],
188        Language::Rust => &[
189            "i8", "i16", "i32", "i64", "i128", "isize",
190            "u8", "u16", "u32", "u64", "u128", "usize",
191            "f32", "f64", "bool", "char", "str", "String",
192            "Vec", "HashMap", "Option", "Result", "Box", "Rc", "Arc",
193        ][..],
194    };
195
196    let builtins = match language {
197        Language::Logos => &[
198            "show", "length", "first", "last", "rest", "append",
199            "contains", "keys", "values", "range",
200        ][..],
201        Language::Vernacular => &[
202            "Zero", "Succ", "Nil", "Cons", "Some", "None",
203            "refl", "eq_ind", "nat_ind", "syn_diag", "syn_quote", "syn_size",
204            "concludes", "Provable", "Consistent",
205        ][..],
206        Language::Rust => &[
207            "println", "print", "format", "vec", "panic", "assert",
208            "Some", "None", "Ok", "Err",
209        ][..],
210    };
211
212    while i < len {
213        let c = chars[i];
214
215        // Skip whitespace (preserve it)
216        if c.is_whitespace() {
217            let start = i;
218            while i < len && chars[i].is_whitespace() {
219                i += 1;
220            }
221            tokens.push(Token {
222                text: chars[start..i].iter().collect(),
223                kind: TokenKind::Identifier, // Whitespace uses default color
224            });
225            continue;
226        }
227
228        // Comments
229        let comment_start = match language {
230            Language::Logos | Language::Vernacular => "--",
231            Language::Rust => "//",
232        };
233        if i + comment_start.len() <= len {
234            let slice: String = chars[i..i + comment_start.len()].iter().collect();
235            if slice == comment_start {
236                let start = i;
237                while i < len && chars[i] != '\n' {
238                    i += 1;
239                }
240                tokens.push(Token {
241                    text: chars[start..i].iter().collect(),
242                    kind: TokenKind::Comment,
243                });
244                continue;
245            }
246        }
247
248        // Strings
249        if c == '"' {
250            let start = i;
251            i += 1;
252            while i < len && chars[i] != '"' {
253                if chars[i] == '\\' && i + 1 < len {
254                    i += 2;
255                } else {
256                    i += 1;
257                }
258            }
259            if i < len {
260                i += 1; // closing quote
261            }
262            tokens.push(Token {
263                text: chars[start..i].iter().collect(),
264                kind: TokenKind::String,
265            });
266            continue;
267        }
268
269        // Numbers
270        if c.is_ascii_digit() {
271            let start = i;
272            while i < len && (chars[i].is_ascii_digit() || chars[i] == '.' || chars[i] == '_') {
273                i += 1;
274            }
275            tokens.push(Token {
276                text: chars[start..i].iter().collect(),
277                kind: TokenKind::Number,
278            });
279            continue;
280        }
281
282        // Identifiers and keywords
283        if c.is_alphabetic() || c == '_' {
284            let start = i;
285            while i < len && (chars[i].is_alphanumeric() || chars[i] == '_') {
286                i += 1;
287            }
288            let word: String = chars[start..i].iter().collect();
289            let kind = if keywords.contains(&word.as_str()) {
290                TokenKind::Keyword
291            } else if types.contains(&word.as_str()) {
292                TokenKind::Type
293            } else if builtins.contains(&word.as_str()) {
294                TokenKind::Builtin
295            } else {
296                TokenKind::Identifier
297            };
298            tokens.push(Token { text: word, kind });
299            continue;
300        }
301
302        // Operators
303        let operators = [
304            "->", "=>", ":=", "<=", ">=", "==", "!=", "&&", "||",
305            "+", "-", "*", "/", "%", "=", "<", ">", "!", "&", "|", "^",
306        ];
307        let mut matched = false;
308        for op in operators {
309            if i + op.len() <= len {
310                let slice: String = chars[i..i + op.len()].iter().collect();
311                if slice == op {
312                    tokens.push(Token {
313                        text: slice,
314                        kind: TokenKind::Operator,
315                    });
316                    i += op.len();
317                    matched = true;
318                    break;
319                }
320            }
321        }
322        if matched {
323            continue;
324        }
325
326        // Punctuation
327        if "(){}[].,;:".contains(c) {
328            tokens.push(Token {
329                text: c.to_string(),
330                kind: TokenKind::Punctuation,
331            });
332            i += 1;
333            continue;
334        }
335
336        // Default: single character
337        tokens.push(Token {
338            text: c.to_string(),
339            kind: TokenKind::Identifier,
340        });
341        i += 1;
342    }
343
344    tokens
345}
346
347/// Get CSS class for a token kind.
348fn token_class(kind: TokenKind) -> &'static str {
349    match kind {
350        TokenKind::Keyword => "tok-keyword",
351        TokenKind::Type => "tok-type",
352        TokenKind::String => "tok-string",
353        TokenKind::Number => "tok-number",
354        TokenKind::Comment => "tok-comment",
355        TokenKind::Operator => "tok-operator",
356        TokenKind::Punctuation => "tok-punctuation",
357        TokenKind::Identifier => "tok-identifier",
358        TokenKind::Builtin => "tok-builtin",
359    }
360}
361
362/// Syntax-highlighted code editor component.
363#[component]
364pub fn CodeEditor(
365    value: String,
366    on_change: EventHandler<String>,
367    language: Language,
368    #[props(default = "Enter code...".to_string())]
369    placeholder: String,
370    #[props(default = false)]
371    readonly: bool,
372) -> Element {
373    let tokens = tokenize(&value, language);
374
375    rsx! {
376        style { "{CODE_EDITOR_STYLE}" }
377
378        div { class: "code-editor",
379            div { class: "code-editor-input",
380                // Highlighted overlay
381                div { class: "code-editor-highlight",
382                    for token in tokens {
383                        if token.text.chars().all(|c| c.is_whitespace()) {
384                            // Output whitespace as raw text to match textarea rendering
385                            "{token.text}"
386                        } else {
387                            span {
388                                class: "{token_class(token.kind)}",
389                                "{token.text}"
390                            }
391                        }
392                    }
393                }
394
395                // Actual textarea for input
396                textarea {
397                    class: "code-editor-textarea",
398                    placeholder: "{placeholder}",
399                    value: "{value}",
400                    readonly: readonly,
401                    spellcheck: "false",
402                    autocomplete: "off",
403                    autocapitalize: "off",
404                    oninput: move |evt| {
405                        on_change.call(evt.value());
406                        // Sync scroll after input in case highlight re-renders
407                        #[cfg(target_arch = "wasm32")]
408                        {
409                            let _ = js_sys::eval(r#"
410                                requestAnimationFrame(function() {
411                                    document.querySelectorAll('.code-editor-textarea').forEach(function(ta) {
412                                        var highlight = ta.previousElementSibling;
413                                        if (highlight && highlight.classList.contains('code-editor-highlight')) {
414                                            var taMax = ta.scrollHeight - ta.clientHeight;
415                                            var hlMax = highlight.scrollHeight - highlight.clientHeight;
416                                            // Cap scroll to the shorter element's max
417                                            var maxScroll = Math.min(taMax, hlMax);
418                                            if (ta.scrollTop > maxScroll) {
419                                                ta.scrollTop = maxScroll;
420                                            }
421                                            highlight.scrollTop = ta.scrollTop;
422                                            highlight.scrollLeft = ta.scrollLeft;
423                                        }
424                                    });
425                                });
426                            "#);
427                        }
428                    },
429                    onscroll: move |_| {
430                        #[cfg(target_arch = "wasm32")]
431                        {
432                            let _ = js_sys::eval(r#"
433                                document.querySelectorAll('.code-editor-textarea').forEach(function(ta) {
434                                    var highlight = ta.previousElementSibling;
435                                    if (highlight && highlight.classList.contains('code-editor-highlight')) {
436                                        var taMax = ta.scrollHeight - ta.clientHeight;
437                                        var hlMax = highlight.scrollHeight - highlight.clientHeight;
438                                        // Cap scroll to the shorter element's max
439                                        var maxScroll = Math.min(taMax, hlMax);
440                                        if (ta.scrollTop > maxScroll) {
441                                            ta.scrollTop = maxScroll;
442                                        }
443                                        highlight.scrollTop = ta.scrollTop;
444                                        highlight.scrollLeft = ta.scrollLeft;
445                                    }
446                                });
447                            "#);
448                        }
449                    },
450                }
451            }
452        }
453    }
454}
455
456/// Read-only syntax-highlighted code view.
457#[component]
458pub fn CodeView(
459    code: String,
460    language: Language,
461) -> Element {
462    let tokens = tokenize(&code, language);
463
464    rsx! {
465        style { "{CODE_EDITOR_STYLE}" }
466
467        div { class: "code-editor",
468            div { class: "code-editor-highlight",
469                style: "position: relative; height: 100%; overflow: auto; pointer-events: auto;",
470                for token in tokens {
471                    if token.text.chars().all(|c| c.is_whitespace()) {
472                        "{token.text}"
473                    } else {
474                        span {
475                            class: "{token_class(token.kind)}",
476                            "{token.text}"
477                        }
478                    }
479                }
480            }
481        }
482    }
483}