1use std::collections::HashSet;
5
6use leptos::prelude::*;
7use wasm_bindgen::JsCast;
8
9pub type Highlighter = fn(&str) -> Vec<(&'static str, String)>;
12
13pub 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#[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#[derive(Clone)]
308pub struct CodeDocument {
309 pub id: String,
311 pub title: String,
313 pub value: RwSignal<String>,
315}
316
317impl CodeDocument {
318 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#[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
470pub fn highlight_rhai(source: &str) -> Vec<(&'static str, String)> {
473 highlight_code(source, RHAI_KEYWORDS, RHAI_COMMANDS)
474}