1use leptos::prelude::*;
5
6fn parse_number(chars: &[char], pos: &mut usize) -> Option<f64> {
7 let start = *pos;
8 while *pos < chars.len() && (chars[*pos].is_ascii_digit() || chars[*pos] == '.') {
9 *pos += 1;
10 }
11 if *pos == start {
12 return None;
13 }
14 chars[start..*pos].iter().collect::<String>().parse().ok()
15}
16
17fn parse_factor(chars: &[char], pos: &mut usize) -> Option<f64> {
18 if *pos >= chars.len() {
19 return None;
20 }
21 match chars[*pos] {
22 '-' => {
23 *pos += 1;
24 Some(-parse_factor(chars, pos)?)
25 }
26 '+' => {
27 *pos += 1;
28 parse_factor(chars, pos)
29 }
30 '(' => {
31 *pos += 1;
32 let value = parse_expr(chars, pos)?;
33 if *pos < chars.len() && chars[*pos] == ')' {
34 *pos += 1;
35 Some(value)
36 } else {
37 None
38 }
39 }
40 _ => parse_number(chars, pos),
41 }
42}
43
44fn parse_term(chars: &[char], pos: &mut usize) -> Option<f64> {
45 let mut left = parse_factor(chars, pos)?;
46 while *pos < chars.len() && matches!(chars[*pos], '*' | '/') {
47 let operator = chars[*pos];
48 *pos += 1;
49 let right = parse_factor(chars, pos)?;
50 left = if operator == '*' {
51 left * right
52 } else {
53 left / right
54 };
55 }
56 Some(left)
57}
58
59fn parse_expr(chars: &[char], pos: &mut usize) -> Option<f64> {
60 let mut left = parse_term(chars, pos)?;
61 while *pos < chars.len() && matches!(chars[*pos], '+' | '-') {
62 let operator = chars[*pos];
63 *pos += 1;
64 let right = parse_term(chars, pos)?;
65 left = if operator == '+' {
66 left + right
67 } else {
68 left - right
69 };
70 }
71 Some(left)
72}
73
74fn eval_expr(input: &str) -> Option<f64> {
75 let chars: Vec<char> = input.chars().filter(|c| !c.is_whitespace()).collect();
76 let mut pos = 0;
77 let value = parse_expr(&chars, &mut pos)?;
78 if pos == chars.len() && value.is_finite() {
79 Some(value)
80 } else {
81 None
82 }
83}
84
85#[component]
90pub fn NumberField(
91 #[prop(into)] label: String,
92 value: Signal<f64>,
93 #[prop(optional)] step: Option<f64>,
94 #[prop(optional)] min: Option<f64>,
95 #[prop(optional)] max: Option<f64>,
96 #[prop(optional)] integer: bool,
97 #[prop(into, optional)] help: String,
98 #[prop(into, optional)] error: String,
99 #[prop(into, optional)] disabled: Signal<bool>,
100 #[prop(optional)] default: Option<f64>,
101 #[prop(optional)] validate: Option<Callback<f64, Option<String>>>,
102 on_change: Callback<(f64, bool)>,
103) -> impl IntoView {
104 let step = step.unwrap_or(if integer { 1.0 } else { 0.1 });
105 let validation = RwSignal::new(String::new());
106 let error_signal = Signal::derive(move || {
107 if error.is_empty() {
108 validation.get()
109 } else {
110 error.clone()
111 }
112 });
113 let clamp = move |parsed: f64| {
114 let mut result = if integer { parsed.round() } else { parsed };
115 if let Some(min) = min {
116 result = result.max(min);
117 }
118 if let Some(max) = max {
119 result = result.min(max);
120 }
121 result
122 };
123 let format_value = move |raw: f64| {
124 if integer {
125 format!("{:.0}", raw.round())
126 } else {
127 format!("{raw:.3}")
128 }
129 };
130 let commit = move |raw: String, committed: bool| {
131 let parsed = raw.parse::<f64>().ok().or_else(|| eval_expr(&raw));
132 match parsed {
133 Some(parsed) => {
134 let clamped = clamp(parsed);
135 if let Some(validate) = validate {
136 validation.set(validate.run(clamped).unwrap_or_default());
137 } else {
138 validation.set(String::new());
139 }
140 on_change.run((clamped, committed));
141 }
142 None if committed && !raw.trim().is_empty() => {
143 validation.set("Enter a number or expression".to_string());
144 }
145 None => {}
146 }
147 };
148 let live = move |raw: String| {
149 if let Ok(parsed) = raw.parse::<f64>() {
150 on_change.run((clamp(parsed), false));
151 }
152 };
153 let label_ref = NodeRef::<leptos::html::Span>::new();
154 let drag = StoredValue::new(None::<(f64, f64)>);
155 let on_scrub_down = move |event: web_sys::PointerEvent| {
156 event.prevent_default();
157 drag.set_value(Some((event.client_x() as f64, value.get_untracked())));
158 if let Some(node) = label_ref.get() {
159 let _ = node.set_pointer_capture(event.pointer_id());
160 }
161 };
162 let on_scrub_move = move |event: web_sys::PointerEvent| {
163 if let Some((start_x, start_value)) = drag.get_value() {
164 let delta = event.client_x() as f64 - start_x;
165 on_change.run((clamp(start_value + delta * step), false));
166 }
167 };
168 let on_scrub_up = move |event: web_sys::PointerEvent| {
169 if drag.get_value().is_some() {
170 drag.set_value(None);
171 on_change.run((value.get_untracked(), true));
172 if let Some(node) = label_ref.get() {
173 let _ = node.release_pointer_capture(event.pointer_id());
174 }
175 }
176 };
177 view! {
178 <div class="nightshade-field-group">
179 <label class="nightshade-field">
180 <span
181 node_ref=label_ref
182 class="nightshade-field-label nightshade-scrub"
183 on:pointerdown=on_scrub_down
184 on:pointermove=on_scrub_move
185 on:pointerup=on_scrub_up
186 >
187 {label}
188 </span>
189 <input
190 type="text"
191 inputmode="decimal"
192 step=step
193 disabled=move || disabled.get()
194 prop:value=move || format_value(value.get())
195 on:input=move |event| live(event_target_value(&event))
196 on:change=move |event| commit(event_target_value(&event), true)
197 />
198 {default
199 .map(|fallback| {
200 view! {
201 <span
202 class="nightshade-field-reset"
203 role="button"
204 tabindex="0"
205 title="Reset to default"
206 class:hidden=move || value.get() == fallback
207 on:click=move |_| on_change.run((fallback, true))
208 >
209 "\u{21ba}"
210 </span>
211 }
212 })}
213 </label>
214 <FieldNote help=help error=error_signal />
215 </div>
216 }
217}
218
219#[component]
223pub fn Vec3Field(
224 #[prop(into)] label: String,
225 value: Signal<[f64; 3]>,
226 #[prop(optional)] step: Option<f64>,
227 #[prop(optional)] min: Option<f64>,
228 #[prop(optional)] max: Option<f64>,
229 #[prop(into, optional)] disabled: Signal<bool>,
230 on_change: Callback<([f64; 3], bool)>,
231) -> impl IntoView {
232 let clamp = move |parsed: f64| {
233 let mut result = parsed;
234 if let Some(min) = min {
235 result = result.max(min);
236 }
237 if let Some(max) = max {
238 result = result.min(max);
239 }
240 result
241 };
242 let axis = move |index: usize, tag: &'static str| {
243 let apply = move |raw: String, committed: bool| {
244 let parsed = if committed {
245 raw.parse::<f64>().ok().or_else(|| eval_expr(&raw))
246 } else {
247 raw.parse::<f64>().ok()
248 };
249 if let Some(parsed) = parsed {
250 let mut next = value.get_untracked();
251 next[index] = clamp(parsed);
252 on_change.run((next, committed));
253 }
254 };
255 view! {
256 <div class="nightshade-vec-axis">
257 <span class="nightshade-vec-tag">{tag}</span>
258 <input
259 type="text"
260 inputmode="decimal"
261 step=step.unwrap_or(0.1)
262 disabled=move || disabled.get()
263 prop:value=move || format!("{:.3}", value.get()[index])
264 on:input=move |event| apply(event_target_value(&event), false)
265 on:change=move |event| apply(event_target_value(&event), true)
266 />
267 </div>
268 }
269 };
270 view! {
271 <div class="nightshade-field-group">
272 <span class="nightshade-field-label">{label}</span>
273 <div class="nightshade-vec-field">
274 {axis(0, "X")} {axis(1, "Y")} {axis(2, "Z")}
275 </div>
276 </div>
277 }
278}
279
280#[component]
283pub fn CheckField(
284 #[prop(into)] label: String,
285 value: Signal<bool>,
286 on_change: Callback<bool>,
287 #[prop(into, optional)] disabled: Signal<bool>,
288) -> impl IntoView {
289 view! {
290 <label class="nightshade-field check">
291 <input
292 type="checkbox"
293 disabled=move || disabled.get()
294 prop:checked=move || value.get()
295 on:change=move |event| on_change.run(event_target_checked(&event))
296 />
297 <span class="nightshade-field-label">{label}</span>
298 </label>
299 }
300}
301
302#[component]
305pub fn Switch(
306 #[prop(into)] label: String,
307 value: Signal<bool>,
308 on_change: Callback<bool>,
309 #[prop(into, optional)] disabled: Signal<bool>,
310) -> impl IntoView {
311 view! {
312 <label class="nightshade-field nightshade-switch-field">
313 <span class="nightshade-field-label">{label}</span>
314 <button
315 type="button"
316 role="switch"
317 class="nightshade-switch"
318 class:on=move || value.get()
319 aria-checked=move || value.get().to_string()
320 disabled=move || disabled.get()
321 on:click=move |_| on_change.run(!value.get_untracked())
322 >
323 <span class="nightshade-switch-thumb"></span>
324 </button>
325 </label>
326 }
327}
328
329#[component]
333pub fn TextField(
334 #[prop(into)] label: String,
335 value: Signal<String>,
336 on_commit: Callback<String>,
337 #[prop(into, optional)] placeholder: String,
338 #[prop(into, optional)] help: String,
339 #[prop(into, optional)] error: String,
340 #[prop(into, optional)] disabled: Signal<bool>,
341 #[prop(optional)] debounce: Option<u32>,
342) -> impl IntoView {
343 let error_signal = Signal::derive(move || error.clone());
344 let generation = StoredValue::new(0u32);
345 let on_input = move |event: web_sys::Event| {
346 let Some(delay) = debounce else {
347 return;
348 };
349 let text = event_target_value(&event);
350 let current = generation.get_value().wrapping_add(1);
351 generation.set_value(current);
352 set_timeout(
353 move || {
354 if generation.get_value() == current {
355 on_commit.run(text.clone());
356 }
357 },
358 std::time::Duration::from_millis(delay as u64),
359 );
360 };
361 view! {
362 <div class="nightshade-field-group">
363 <label class="nightshade-field">
364 <span class="nightshade-field-label">{label}</span>
365 <input
366 type="text"
367 placeholder=placeholder
368 disabled=move || disabled.get()
369 prop:value=move || value.get()
370 on:input=on_input
371 on:change=move |event| {
372 if debounce.is_none() {
373 on_commit.run(event_target_value(&event));
374 }
375 }
376 />
377 </label>
378 <FieldNote help=help error=error_signal />
379 </div>
380 }
381}
382
383#[component]
387pub fn SliderField(
388 #[prop(into)] label: String,
389 value: Signal<f64>,
390 #[prop(into)] min: Signal<f64>,
391 #[prop(into)] max: Signal<f64>,
392 #[prop(default = 0.01)] step: f64,
393 #[prop(into, optional)] disabled: Signal<bool>,
394 on_change: Callback<(f64, bool)>,
395) -> impl IntoView {
396 view! {
397 <label class="nightshade-field">
398 <span class="nightshade-field-label">{label}</span>
399 <input
400 type="range"
401 min=move || min.get()
402 max=move || max.get()
403 step=step
404 disabled=move || disabled.get()
405 prop:value=move || value.get()
406 on:input=move |event| {
407 if let Ok(parsed) = event_target_value(&event).parse::<f64>() {
408 on_change.run((parsed, false));
409 }
410 }
411 on:change=move |event| {
412 if let Ok(parsed) = event_target_value(&event).parse::<f64>() {
413 on_change.run((parsed, true));
414 }
415 }
416 />
417 <span class="nightshade-field-value">{move || format!("{:.2}", value.get())}</span>
418 </label>
419 }
420}
421
422#[component]
426pub fn ColorField(
427 #[prop(into)] label: String,
428 value: Signal<[f32; 3]>,
429 on_change: Callback<([f32; 3], bool)>,
430 #[prop(into, optional)] disabled: Signal<bool>,
431) -> impl IntoView {
432 view! {
433 <label class="nightshade-field">
434 <span class="nightshade-field-label">{label}</span>
435 <input
436 type="color"
437 disabled=move || disabled.get()
438 prop:value=move || rgb_to_hex(value.get())
439 on:input=move |event| {
440 if let Some(rgb) = hex_to_rgb(&event_target_value(&event)) {
441 on_change.run((rgb, false));
442 }
443 }
444 on:change=move |event| {
445 if let Some(rgb) = hex_to_rgb(&event_target_value(&event)) {
446 on_change.run((rgb, true));
447 }
448 }
449 />
450 </label>
451 }
452}
453
454#[component]
457pub fn Select(
458 #[prop(into)] label: String,
459 value: Signal<String>,
460 options: Vec<(String, String)>,
461 on_change: Callback<String>,
462 #[prop(into, optional)] disabled: Signal<bool>,
463) -> impl IntoView {
464 view! {
465 <label class="nightshade-field">
466 <span class="nightshade-field-label">{label}</span>
467 <select
468 class="nightshade-select"
469 disabled=move || disabled.get()
470 prop:value=move || value.get()
471 on:change=move |event| on_change.run(event_target_value(&event))
472 >
473 {options
474 .into_iter()
475 .map(|(option_value, text)| view! { <option value=option_value>{text}</option> })
476 .collect_view()}
477 </select>
478 </label>
479 }
480}
481
482#[component]
484pub fn ChipGroup(#[prop(into, optional)] class: String, children: Children) -> impl IntoView {
485 view! { <div class=format!("nightshade-chip-group {class}")>{children()}</div> }
486}
487
488#[component]
491pub fn ToggleChip(
492 #[prop(into)] label: String,
493 #[prop(into)] active: Signal<bool>,
494 on_toggle: Callback<()>,
495 #[prop(into, optional)] disabled: Signal<bool>,
496) -> impl IntoView {
497 view! {
498 <button
499 type="button"
500 class="nightshade-chip"
501 class:active=move || active.get()
502 aria-pressed=move || active.get().to_string()
503 disabled=move || disabled.get()
504 on:click=move |_| on_toggle.run(())
505 >
506 {label}
507 </button>
508 }
509}
510
511#[component]
514pub fn TagInput(
515 #[prop(into)] tags: Signal<Vec<String>>,
516 on_add: Callback<String>,
517 on_remove: Callback<String>,
518 #[prop(into, optional)] placeholder: String,
519) -> impl IntoView {
520 let draft = RwSignal::new(String::new());
521 let placeholder = if placeholder.is_empty() {
522 "Add tag…".to_string()
523 } else {
524 placeholder
525 };
526 let submit = move || {
527 let value = draft.get_untracked().trim().to_string();
528 if !value.is_empty() {
529 on_add.run(value);
530 draft.set(String::new());
531 }
532 };
533 view! {
534 <div class="nightshade-tag-input">
535 <For each=move || tags.get() key=|tag| tag.clone() let:tag>
536 {
537 let removed = tag.clone();
538 view! {
539 <span class="nightshade-tag">
540 {tag.clone()}
541 <button
542 class="nightshade-tag-remove"
543 aria-label="Remove tag"
544 on:click=move |_| on_remove.run(removed.clone())
545 >
546 "\u{00d7}"
547 </button>
548 </span>
549 }
550 }
551 </For>
552 <input
553 type="text"
554 class="nightshade-tag-field"
555 placeholder=placeholder
556 prop:value=move || draft.get()
557 on:input=move |event| draft.set(event_target_value(&event))
558 on:keydown=move |event| {
559 if event.key() == "Enter" {
560 event.prevent_default();
561 submit();
562 }
563 }
564 />
565 </div>
566 }
567}
568
569#[component]
572pub fn Swatch(
573 #[prop(into)] color: String,
574 #[prop(into, optional)] active: Signal<bool>,
575 #[prop(optional)] on_select: Option<Callback<()>>,
576) -> impl IntoView {
577 view! {
578 <button
579 type="button"
580 class="nightshade-swatch"
581 class:active=move || active.get()
582 title=color.clone()
583 style=format!("background:{color}")
584 on:click=move |_| {
585 if let Some(callback) = on_select {
586 callback.run(());
587 }
588 }
589 ></button>
590 }
591}
592
593#[component]
596pub fn SwatchPalette(
597 colors: Vec<String>,
598 #[prop(into)] selected: Signal<String>,
599 on_select: Callback<String>,
600) -> impl IntoView {
601 view! {
602 <div class="nightshade-swatch-palette">
603 {colors
604 .into_iter()
605 .map(|color| {
606 let value = color.clone();
607 let compare = color.clone();
608 view! {
609 <Swatch
610 color=color
611 active=Signal::derive(move || selected.get() == compare)
612 on_select=Callback::new(move |_| on_select.run(value.clone()))
613 />
614 }
615 })
616 .collect_view()}
617 </div>
618 }
619}
620
621#[component]
622fn FieldNote(
623 #[prop(into, optional)] help: String,
624 #[prop(into)] error: Signal<String>,
625) -> impl IntoView {
626 let show_help = !help.is_empty();
627 view! {
628 <Show when=move || !error.get().is_empty() fallback=|| ()>
629 <div class="nightshade-field-footer">
630 <span class="nightshade-field-error">{move || error.get()}</span>
631 </div>
632 </Show>
633 {show_help
634 .then(|| {
635 let help = help.clone();
636 view! {
637 <Show when=move || error.get().is_empty() fallback=|| ()>
638 <div class="nightshade-field-footer">
639 <span class="nightshade-field-help">{help.clone()}</span>
640 </div>
641 </Show>
642 }
643 })}
644 }
645}
646
647fn rgb_to_hex(rgb: [f32; 3]) -> String {
648 format!(
649 "#{:02x}{:02x}{:02x}",
650 (rgb[0].clamp(0.0, 1.0) * 255.0) as u8,
651 (rgb[1].clamp(0.0, 1.0) * 255.0) as u8,
652 (rgb[2].clamp(0.0, 1.0) * 255.0) as u8,
653 )
654}
655
656fn hex_to_rgb(hex: &str) -> Option<[f32; 3]> {
657 let hex = hex.strip_prefix('#')?;
658 if hex.len() != 6 {
659 return None;
660 }
661 let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
662 let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
663 let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
664 Some([
665 red as f32 / 255.0,
666 green as f32 / 255.0,
667 blue as f32 / 255.0,
668 ])
669}
670
671#[cfg(test)]
672mod tests {
673 use super::{eval_expr, hex_to_rgb, rgb_to_hex};
674
675 #[test]
676 fn hex_round_trips_through_rgb() {
677 assert_eq!(rgb_to_hex([1.0, 0.0, 0.0]), "#ff0000");
678 assert_eq!(rgb_to_hex([0.0, 1.0, 0.0]), "#00ff00");
679 let parsed = hex_to_rgb("#3366cc").expect("valid hex");
680 assert_eq!(rgb_to_hex(parsed), "#3366cc");
681 }
682
683 #[test]
684 fn malformed_hex_is_rejected() {
685 assert!(hex_to_rgb("3366cc").is_none());
686 assert!(hex_to_rgb("#fff").is_none());
687 assert!(hex_to_rgb("#gggggg").is_none());
688 }
689
690 #[test]
691 fn evaluates_arithmetic_with_precedence_and_parens() {
692 assert_eq!(eval_expr("2+3*4"), Some(14.0));
693 assert_eq!(eval_expr("(2+3)*4"), Some(20.0));
694 assert_eq!(eval_expr("-1.5 + 2"), Some(0.5));
695 assert_eq!(eval_expr("10/4"), Some(2.5));
696 }
697
698 #[test]
699 fn rejects_malformed_expressions() {
700 assert!(eval_expr("2+").is_none());
701 assert!(eval_expr("abc").is_none());
702 assert!(eval_expr("1/0").is_none());
703 assert!(eval_expr("(1+2").is_none());
704 }
705}