Skip to main content

nightshade_api/web/
forms.rs

1//! Form field components: numeric, text, boolean, slider, color, select, chip,
2//! tag, and swatch inputs sharing the `nightshade-field` styling conventions.
3
4use 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/// A numeric field that evaluates arithmetic expressions, supports drag-to-scrub on its
86/// label, optional reset-to-default, clamping to `min`/`max`, integer rounding, and live
87/// validation. Emits `(value, committed)` through `on_change`, where `committed` marks
88/// the final value on blur, Enter, or scrub end.
89#[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/// Three numeric inputs labelled X, Y, and Z for editing a `[f64; 3]` vector. Each axis
220/// clamps to `min`/`max` and evaluates arithmetic expressions on commit. Emits the full
221/// updated array with a `committed` flag through `on_change`.
222#[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/// A labelled checkbox bound to a `bool` signal. Emits the new checked state through
281/// `on_change`.
282#[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/// A labelled toggle switch (an ARIA `switch` button) bound to a `bool` signal. Emits the
303/// toggled state through `on_change`.
304#[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/// A single-line text field with optional help and error notes. Commits on change, or
330/// debounces input by `debounce` milliseconds when set, emitting the text through
331/// `on_commit`.
332#[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/// A range slider bound to a `f64` signal with reactive `min`, `max`, and a fixed `step`,
384/// showing the current value. Emits `(value, committed)` through `on_change`, with
385/// `committed` false during drag and true on release.
386#[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/// A native color picker bound to an `[f32; 3]` RGB signal (components in `0.0..=1.0`).
423/// Emits `(rgb, committed)` through `on_change`, with `committed` false during input and
424/// true on change.
425#[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/// A labelled dropdown built from `(value, text)` option pairs, bound to a `String`
455/// signal. Emits the selected option value through `on_change`.
456#[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/// A flex container that lays out chip children, with an optional extra `class`.
483#[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/// A pill-shaped toggle button reflecting an `active` signal via `aria-pressed`. Emits a
489/// unit event through `on_toggle` when clicked.
490#[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/// An editable list of tags with a text entry that adds a trimmed tag on Enter. Emits new
512/// tags through `on_add` and removed tags through `on_remove`.
513#[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/// A single color swatch button showing `color` as its background, marked active via the
570/// `active` signal. Runs the optional `on_select` callback when clicked.
571#[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/// A row of [`Swatch`] buttons built from `colors`, highlighting the one matching the
594/// `selected` signal. Emits the chosen color string through `on_select`.
595#[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}