Skip to main content

nightshade_api/web/
code_editor.rs

1//! Textarea-backed code editor with syntax overlay, gutter, find/replace, and a
2//! simple built-in tokenizer, plus a tabbed multi-document wrapper.
3
4use std::collections::HashSet;
5
6use leptos::prelude::*;
7use wasm_bindgen::JsCast;
8
9/// A syntax highlighter: maps source text to a sequence of `(css-class, text)`
10/// runs that are rendered as styled spans behind the editor's textarea.
11pub type Highlighter = fn(&str) -> Vec<(&'static str, String)>;
12
13/// Tokenizes `source` into highlighted runs, classifying the given `keywords`
14/// and `commands` words while recognizing comments, strings, and numbers.
15pub fn highlight_code(
16    source: &str,
17    keywords: &[&'static str],
18    commands: &[&'static str],
19) -> Vec<(&'static str, String)> {
20    let keyword_set: HashSet<&'static str> = keywords.iter().copied().collect();
21    let command_set: HashSet<&'static str> = commands.iter().copied().collect();
22    scan(source, &keyword_set, &command_set)
23}
24
25fn scan(
26    source: &str,
27    keywords: &HashSet<&'static str>,
28    commands: &HashSet<&'static str>,
29) -> Vec<(&'static str, String)> {
30    let characters: Vec<char> = source.chars().collect();
31    let count = characters.len();
32    let mut runs: Vec<(&'static str, String)> = Vec::new();
33    let mut index = 0;
34    while index < count {
35        let current = characters[index];
36        if current == '/' && index + 1 < count && characters[index + 1] == '*' {
37            let start = index;
38            index += 2;
39            while index < count
40                && !(characters[index] == '*' && index + 1 < count && characters[index + 1] == '/')
41            {
42                index += 1;
43            }
44            index = (index + 2).min(count);
45            runs.push(("tok-comment", characters[start..index].iter().collect()));
46        } else if current == '/' && index + 1 < count && characters[index + 1] == '/' {
47            let start = index;
48            while index < count && characters[index] != '\n' {
49                index += 1;
50            }
51            runs.push(("tok-comment", characters[start..index].iter().collect()));
52        } else if current == '"' {
53            let start = index;
54            index += 1;
55            while index < count {
56                if characters[index] == '\\' && index + 1 < count {
57                    index += 2;
58                    continue;
59                }
60                let quote = characters[index] == '"';
61                index += 1;
62                if quote {
63                    break;
64                }
65            }
66            runs.push(("tok-string", characters[start..index].iter().collect()));
67        } else if current.is_ascii_digit() {
68            let start = index;
69            while index < count
70                && (characters[index].is_ascii_alphanumeric() || characters[index] == '.')
71            {
72                index += 1;
73            }
74            runs.push(("tok-number", characters[start..index].iter().collect()));
75        } else if current.is_alphabetic() || current == '_' {
76            let start = index;
77            while index < count && (characters[index].is_alphanumeric() || characters[index] == '_')
78            {
79                index += 1;
80            }
81            let word: String = characters[start..index].iter().collect();
82            let class = if keywords.contains(word.as_str()) {
83                "tok-keyword"
84            } else if commands.contains(word.as_str()) {
85                "tok-command"
86            } else {
87                "tok-plain"
88            };
89            runs.push((class, word));
90        } else {
91            let start = index;
92            index += 1;
93            while index < count {
94                let next = characters[index];
95                let token_start = (next == '/'
96                    && index + 1 < count
97                    && (characters[index + 1] == '/' || characters[index + 1] == '*'))
98                    || next == '"'
99                    || next.is_ascii_digit()
100                    || next.is_alphabetic()
101                    || next == '_';
102                if token_start {
103                    break;
104                }
105                index += 1;
106            }
107            runs.push(("tok-plain", characters[start..index].iter().collect()));
108        }
109    }
110    runs
111}
112
113/// A single-buffer code editor: a transparent `textarea` over a highlighted
114/// `<pre>` overlay, with an optional line-number `gutter` (marking lines in
115/// `diagnostics` as errors), a fixed `height` or `fill` mode, and an optional
116/// Ctrl/Cmd+F `find` bar for find/replace over the `value` signal.
117#[component]
118pub fn CodeEditor(
119    value: RwSignal<String>,
120    #[prop(optional)] highlighter: Option<Highlighter>,
121    #[prop(into, optional, default = "240px".to_string())] height: String,
122    #[prop(optional)] fill: bool,
123    #[prop(optional)] gutter: bool,
124    #[prop(into, optional)] diagnostics: Signal<Vec<usize>>,
125    #[prop(optional)] find: bool,
126) -> impl IntoView {
127    let pre_ref = NodeRef::<leptos::html::Pre>::new();
128    let gutter_ref = NodeRef::<leptos::html::Div>::new();
129    let area_ref = NodeRef::<leptos::html::Textarea>::new();
130    let find_open = RwSignal::new(false);
131    let query = RwSignal::new(String::new());
132    let replacement = RwSignal::new(String::new());
133    let class = if fill {
134        "nightshade-code-editor fill"
135    } else {
136        "nightshade-code-editor"
137    };
138    let style = (!fill).then(|| format!("height:{height}"));
139
140    let spans = move || {
141        let text = value.get();
142        match highlighter {
143            Some(highlight) => highlight(&text),
144            None => vec![("tok-plain", text)],
145        }
146    };
147
148    let line_count = move || value.get().lines().count().max(1);
149
150    let on_scroll = move |event: web_sys::Event| {
151        let area = event
152            .target()
153            .and_then(|target| target.dyn_into::<web_sys::HtmlElement>().ok());
154        if let Some(area) = area {
155            if let Some(pre) = pre_ref.get() {
156                pre.set_scroll_top(area.scroll_top());
157                pre.set_scroll_left(area.scroll_left());
158            }
159            if let Some(gutter) = gutter_ref.get() {
160                gutter.set_scroll_top(area.scroll_top());
161            }
162        }
163    };
164
165    let find_next = move || {
166        let needle = query.get_untracked();
167        if needle.is_empty() {
168            return;
169        }
170        if let Some(area) = area_ref.get() {
171            let text = value.get_untracked();
172            let from = area.selection_end().ok().flatten().unwrap_or(0) as usize;
173            let found = text
174                .get(from.min(text.len())..)
175                .and_then(|rest| rest.find(&needle))
176                .map(|offset| from + offset)
177                .or_else(|| text.find(&needle));
178            if let Some(position) = found {
179                let _ = area.focus();
180                let _ = area.set_selection_range(position as u32, (position + needle.len()) as u32);
181            }
182        }
183    };
184
185    let replace_one = move || {
186        let needle = query.get_untracked();
187        let with = replacement.get_untracked();
188        if needle.is_empty() {
189            return;
190        }
191        if let Some(area) = area_ref.get() {
192            let start = area.selection_start().ok().flatten().unwrap_or(0) as usize;
193            let end = area.selection_end().ok().flatten().unwrap_or(0) as usize;
194            let text = value.get_untracked();
195            if end > start && text.get(start..end) == Some(needle.as_str()) {
196                let mut next = text.clone();
197                next.replace_range(start..end, &with);
198                value.set(next);
199            }
200        }
201        find_next();
202    };
203
204    let replace_all = move || {
205        let needle = query.get_untracked();
206        if needle.is_empty() {
207            return;
208        }
209        let with = replacement.get_untracked();
210        value.set(value.get_untracked().replace(&needle, &with));
211    };
212
213    let on_editor_key = move |event: web_sys::KeyboardEvent| {
214        if find && (event.ctrl_key() || event.meta_key()) && event.key() == "f" {
215            event.prevent_default();
216            find_open.set(true);
217        }
218    };
219
220    view! {
221        <div class=class style=style on:keydown=on_editor_key>
222            {find
223                .then(|| {
224                    view! {
225                        <Show when=move || find_open.get() fallback=|| ()>
226                            <div class="nightshade-code-find">
227                                <input
228                                    class="nightshade-code-find-input"
229                                    placeholder="Find"
230                                    prop:value=move || query.get()
231                                    on:input=move |event| query.set(event_target_value(&event))
232                                    on:keydown=move |event| {
233                                        if event.key() == "Enter" {
234                                            event.prevent_default();
235                                            find_next();
236                                        }
237                                    }
238                                />
239                                <input
240                                    class="nightshade-code-find-input"
241                                    placeholder="Replace"
242                                    prop:value=move || replacement.get()
243                                    on:input=move |event| replacement.set(event_target_value(&event))
244                                />
245                                <button class="nightshade-button" on:click=move |_| find_next()>
246                                    "Next"
247                                </button>
248                                <button class="nightshade-button" on:click=move |_| replace_one()>
249                                    "Replace"
250                                </button>
251                                <button class="nightshade-button" on:click=move |_| replace_all()>
252                                    "All"
253                                </button>
254                                <button class="nightshade-button" on:click=move |_| find_open.set(false)>
255                                    "\u{00d7}"
256                                </button>
257                            </div>
258                        </Show>
259                    }
260                })}
261            <div class="nightshade-code-columns">
262                {gutter
263                .then(|| {
264                    view! {
265                        <div class="nightshade-code-gutter" node_ref=gutter_ref>
266                            {move || {
267                                let errors = diagnostics.get();
268                                (1..=line_count())
269                                    .map(|line| {
270                                        let is_error = errors.contains(&line);
271                                        view! {
272                                            <div class="nightshade-code-gutter-line" class:error=is_error>
273                                                {line}
274                                            </div>
275                                        }
276                                    })
277                                    .collect_view()
278                            }}
279                        </div>
280                    }
281                })}
282            <div class="nightshade-code-surface">
283                <pre node_ref=pre_ref class="nightshade-code-highlight" aria-hidden="true">
284                    {move || {
285                        spans()
286                            .into_iter()
287                            .map(|(class, text)| view! { <span class=class>{text}</span> })
288                            .collect_view()
289                    }}
290                </pre>
291                <textarea
292                    node_ref=area_ref
293                    class="nightshade-code-textarea"
294                    spellcheck="false"
295                    prop:value=move || value.get()
296                    on:input=move |event| value.set(event_target_value(&event))
297                    on:scroll=on_scroll
298                ></textarea>
299            </div>
300            </div>
301        </div>
302    }
303}
304
305/// One open document in a [`CodeTabs`] set: a stable `id`, a display `title`,
306/// and a reactive `value` buffer shared with the editor.
307#[derive(Clone)]
308pub struct CodeDocument {
309    /// Stable identifier used to select the active tab.
310    pub id: String,
311    /// Label shown on the tab.
312    pub title: String,
313    /// Reactive text buffer edited when this document is active.
314    pub value: RwSignal<String>,
315}
316
317impl CodeDocument {
318    /// Builds a document from an `id`, `title`, and shared `value` buffer.
319    pub fn new(id: impl Into<String>, title: impl Into<String>, value: RwSignal<String>) -> Self {
320        Self {
321            id: id.into(),
322            title: title.into(),
323            value,
324        }
325    }
326}
327
328/// A tabbed multi-document editor: renders a tab bar for `documents`, tracks
329/// the `active` document id, hosts a filling [`CodeEditor`] for the selection,
330/// and fires the optional `on_close` callback with a document id when its close
331/// button is clicked.
332#[component]
333pub fn CodeTabs(
334    #[prop(into)] documents: Signal<Vec<CodeDocument>>,
335    active: RwSignal<String>,
336    #[prop(optional)] highlighter: Option<Highlighter>,
337    #[prop(optional)] on_close: Option<Callback<String>>,
338    #[prop(optional)] find: bool,
339) -> impl IntoView {
340    let active_doc = move || {
341        documents
342            .get()
343            .into_iter()
344            .find(|document| document.id == active.get())
345    };
346    view! {
347        <div class="nightshade-code-tabs">
348            <div class="nightshade-code-tabbar" role="tablist">
349                {move || {
350                    documents
351                        .get()
352                        .into_iter()
353                        .map(|document| {
354                            let id_select = document.id.clone();
355                            let id_close = document.id.clone();
356                            let id_active = document.id.clone();
357                            let is_active = move || active.get() == id_active;
358                            view! {
359                                <div class="nightshade-code-tab" class:active=is_active>
360                                    <button
361                                        class="nightshade-code-tab-label"
362                                        on:click=move |_| active.set(id_select.clone())
363                                    >
364                                        {document.title}
365                                    </button>
366                                    {on_close
367                                        .map(|callback| {
368                                            view! {
369                                                <button
370                                                    class="nightshade-code-tab-close"
371                                                    aria-label="Close"
372                                                    on:click=move |_| callback.run(id_close.clone())
373                                                >
374                                                    "\u{00d7}"
375                                                </button>
376                                            }
377                                        })}
378                                </div>
379                            }
380                        })
381                        .collect_view()
382                }}
383            </div>
384            <div class="nightshade-code-tab-body">
385                {move || match (active_doc(), highlighter) {
386                    (Some(document), Some(highlighter)) => {
387                        view! {
388                            <CodeEditor
389                                value=document.value
390                                highlighter=highlighter
391                                fill=true
392                                find=find
393                            />
394                        }
395                            .into_any()
396                    }
397                    (Some(document), None) => {
398                        view! { <CodeEditor value=document.value fill=true find=find /> }.into_any()
399                    }
400                    (None, _) => {
401                        view! { <div class="nightshade-code-empty">"No open document"</div> }.into_any()
402                    }
403                }}
404            </div>
405        </div>
406    }
407}
408
409const RHAI_KEYWORDS: &[&str] = &[
410    "fn", "let", "const", "if", "else", "for", "in", "while", "loop", "return", "break",
411    "continue", "switch", "import", "export", "global", "private", "true", "false", "throw", "try",
412    "catch", "this",
413];
414
415const RHAI_COMMANDS: &[&str] = &[
416    "commands",
417    "spawn_floor",
418    "spawn_object",
419    "spawn_cube",
420    "spawn_sphere",
421    "spawn_cylinder",
422    "spawn_cone",
423    "spawn_plane",
424    "spawn_torus",
425    "spawn_label",
426    "spawn_text",
427    "point_light",
428    "spot_light",
429    "set_sun",
430    "set_emissive",
431    "set_color",
432    "set_bloom",
433    "set_metallic_roughness",
434    "set_background",
435    "set_ambient",
436    "set_texture",
437    "set_texture_tiling",
438    "set_unlit",
439    "set_visible",
440    "set_parent",
441    "draw_cube",
442    "draw_sphere",
443    "draw_line",
444    "emit_firework",
445    "emit_burst",
446    "emit_particles",
447    "emit_fire",
448    "emit_smoke",
449    "rotate",
450    "set_position",
451    "set_scale",
452    "set_rotation",
453    "despawn",
454    "push",
455    "set_velocity",
456    "apply_force",
457    "last",
458    "result",
459    "tag",
460    "entity_ref",
461    "hsv",
462    "rgb",
463    "rgba",
464    "random",
465    "random_range",
466    "random_int",
467    "log",
468];
469
470/// Tokenizes Rhai `source` into class-name and text pairs for syntax highlighting,
471/// recognizing the language keywords and the scene scripting commands.
472pub fn highlight_rhai(source: &str) -> Vec<(&'static str, String)> {
473    highlight_code(source, RHAI_KEYWORDS, RHAI_COMMANDS)
474}