Skip to main content

brep_app/
form.rs

1//! The generic, schema-driven egui form engine — ONE per-field renderer for BOTH
2//! the display-settings dialog AND the schema-driven feature dialogs (the user's
3//! principle: the structure of a thing drives the UI; you don't rewrite UI code
4//! per field, and you don't write two dialog engines).
5//!
6//! [`field_input`] emits ONLY the input widget(s) for one [`FormField`] — no
7//! leading label, no forced row layout — so the CALLER owns the placement. Both
8//! side trees drive it DIRECTLY: the history feature tree (`panels::history`) and
9//! the settings tree (`panels::settings`) each render a field as a `tree` LEAF
10//! whose node label is the field label and whose right-aligned content is this
11//! input. The widget per [`FieldKind`]:
12//!   * `Color`   → `color_edit_button_srgb`
13//!   * `Bool`    → checkbox
14//!   * `Enum`    → combo box
15//!   * `Number`/`Range` → slider (bounded settings)
16//!   * `Scalar`  → an EXPRESSION-CAPABLE single-line field (unbounded feature
17//!     numbers): the user types a plain number OR a variable name / inline
18//!     equation (`width * 2`) that the kernel evaluates against the history's
19//!     `expressions` sheet; while focused, the mouse wheel steps a pure number.
20//!   * `Text`    → single-line edit (disabled for a read-only `id`)
21//!   * `Vec3`    → three drag values (transform position/rotation/scale)
22//!   * `Button`  → an action button; a click is surfaced to the caller by the
23//!     field key (via the `clicked` out-param) — e.g. `editSketch` opens the
24//!     engine-native sketcher.
25//!   * `Reference` → delegated to a [`ReferenceRenderer`] (the engine-native
26//!     picker) when the caller supplies one, else an inert placeholder.
27//!
28//! A field binds to a `path` (a chain of JSON object keys), so it can read/write
29//! a NESTED value (`["transform","position"]`, `["boolean","operation"]`), not
30//! just a top-level key. On any change it writes back into `current` (the JSON
31//! document being edited — settings JSON, or a feature's `inputParams`) and
32//! returns `true`; the caller re-applies / re-runs. There is deliberately NO
33//! per-field code, so this file is reusable verbatim by a later `brep-ui` crate.
34
35use brep_render::style::{parse_css_hex, FieldKind, FormField};
36use eframe::egui;
37use serde_json::Value;
38use std::collections::HashMap;
39
40/// The engine-agnostic seam for a REAL reference-selection widget. [`field_input`]
41/// stays free of any engine dependency (a later `brep-ui` crate reuses it
42/// verbatim); a caller that CAN pick — the feature dialog, which holds
43/// `&mut EngineState` — passes a `&mut dyn ReferenceRenderer` and `field_input`
44/// delegates each [`FieldKind::Reference`] field to it. When no renderer is
45/// supplied (the settings panel, which has no reference fields) it falls back to
46/// the inert placeholder.
47pub trait ReferenceRenderer {
48    /// Draw the COLLAPSED reference field — an activation button plus the current
49    /// selection listed one-per-line, each line with an X to remove it. Returns
50    /// `(changed, activation_button_rect)`: `changed` is true when a name was
51    /// removed (so the caller commits the edited `current` back to the engine).
52    /// `probe`, when present, receives the activation + per-line-X widget rects
53    /// (keyed `"<path>#activate"`, `"<path>#x<index>"`) for the headed verifier.
54    fn render_reference(
55        &mut self,
56        ui: &mut egui::Ui,
57        field: &FormField,
58        filter: &[String],
59        multiple: bool,
60        current: &mut Value,
61        probe: Option<&mut HashMap<String, egui::Rect>>,
62    ) -> (bool, egui::Rect);
63}
64
65/// Emit ONLY the input widget(s) for one field — NO leading label, NO forced row
66/// layout (the caller supplies the layout — the history + settings trees place
67/// the field label in the tree node and this input in the row's right-aligned
68/// content). Writes any change back into `current` at the field's
69/// `path`, returns `(changed, interactive_widget_rect)`. `probe`, when present,
70/// additionally receives each OPEN enum item's rect (keyed `"<path>#<variant>"`)
71/// for the headed verifier. A `Reference` delegates to `ref_renderer` (the
72/// engine-native picker) when one is supplied — it draws its own label + Select +
73/// per-line-X list — else an inert placeholder.
74pub fn field_input(
75    ui: &mut egui::Ui,
76    field: &FormField,
77    current: &mut Value,
78    mut probe: Option<&mut HashMap<String, egui::Rect>>,
79    ref_renderer: Option<&mut dyn ReferenceRenderer>,
80    clicked: &mut Option<String>,
81) -> (bool, egui::Rect) {
82    let path = &field.path;
83    match &field.kind {
84        FieldKind::Color => {
85            let mut rgb = read_rgb(value_at(current, path));
86            let r = ui.color_edit_button_srgb(&mut rgb);
87            if r.changed() {
88                set_at(current, path, Value::String(rgb_to_hex(rgb)));
89            }
90            (r.changed(), r.rect)
91        }
92        FieldKind::Bool => {
93            let mut b = value_at(current, path).and_then(Value::as_bool).unwrap_or(false);
94            let r = ui.checkbox(&mut b, "");
95            if r.changed() {
96                set_at(current, path, Value::Bool(b));
97            }
98            (r.changed(), r.rect)
99        }
100        FieldKind::Enum { variants } => {
101            let orig = value_at(current, path)
102                .and_then(Value::as_str)
103                .unwrap_or("")
104                .to_string();
105            let mut sel = orig.clone();
106            let combo = egui::ComboBox::from_id_salt(("form-enum", field.key()))
107                .selected_text(&sel)
108                .show_ui(ui, |ui| {
109                    for v in variants {
110                        let item = ui.selectable_value(&mut sel, (*v).to_string(), *v);
111                        if let Some(map) = probe.as_deref_mut() {
112                            map.insert(format!("{}#{}", path.join("."), v), item.rect);
113                        }
114                    }
115                });
116            let changed = sel != orig;
117            if changed {
118                set_at(current, path, Value::String(sel));
119            }
120            (changed, combo.response.rect)
121        }
122        FieldKind::Number { min, max, step } | FieldKind::Range { min, max, step } => {
123            let mut v = value_at(current, path).and_then(Value::as_f64).unwrap_or(*min);
124            let r = ui.add(egui::Slider::new(&mut v, *min..=*max).step_by(*step));
125            if r.changed() {
126                set_at(current, path, serde_json::json!(v));
127            }
128            (r.changed(), r.rect)
129        }
130        FieldKind::Scalar { step } => {
131            // An EXPRESSION-CAPABLE numeric field. It shows a stored NUMBER as text
132            // and a stored EXPRESSION (`width * 2`) VERBATIM — never clobbering a
133            // string param to `0` the way the old number-only `DragValue` did — and
134            // lets the user type either. A transient edit buffer lives in egui
135            // memory (keyed per widget location) and is committed on focus-loss
136            // (Enter / click-away), mirroring `panels::expressions`, so a half-typed
137            // expression (`width *`) never re-runs the history mid-keystroke. While
138            // the field is focused, the mouse wheel STEPS a pure number by `step`
139            // (recovering the old drag-value stepping); a scroll notch is a
140            // complete, valid edit, so it commits immediately.
141            let buf_id = ui.make_persistent_id(("scalar-edit", path.join(".")));
142            let stored_text = scalar_display(value_at(current, path));
143            // Seed from the live buffer while editing; otherwise from the stored
144            // value (an undo / gizmo edit may have changed it out from under us).
145            let mut buf = ui
146                .data_mut(|d| d.get_temp::<String>(buf_id))
147                .unwrap_or_else(|| stored_text.clone());
148
149            let r = ui.add(
150                egui::TextEdit::singleline(&mut buf)
151                    .id(buf_id)
152                    .desired_width(72.0),
153            );
154
155            let mut changed = false;
156            if r.gained_focus() {
157                // Start each edit from the current stored value.
158                buf = stored_text.clone();
159            }
160            // Scroll-to-step a PURE number by `step` (wheel up = +step) while the
161            // field is focused AND the pointer is over it ("scroll over the field
162            // to step it"). A non-numeric expression is un-steppable
163            // (`scroll_step_scalar` → `None`) and left untouched — typing still works.
164            // Step ONLY when the cursor is actually over this field, so a focused
165            // field doesn't swallow panel scrolling while the user scrolls
166            // elsewhere to navigate (that would silently edit the value). Two
167            // independent "pointer is over me" signals for robustness — a real
168            // wheel carries the cursor position on native/desktop; only synthetic
169            // (headless-test) wheels lack it.
170            let pointer_over_field = r.hovered()
171                || ui
172                    .input(|i| i.pointer.latest_pos())
173                    .is_some_and(|pos| r.rect.contains(pos));
174            if r.has_focus() && pointer_over_field {
175                let notches = wheel_notches(ui);
176                // EAT the wheel so the enclosing side-panel `ScrollArea` can't ALSO
177                // scroll the panel — the field owns the scroll while the cursor is
178                // over it. Do this EVERY frame the cursor is here, not only on the
179                // notch frame: egui SMOOTHS a wheel notch across several frames, and
180                // only the first carries a `MouseWheel` event, so zeroing just that
181                // frame let the smoothed TAIL leak into the panel (visible on the
182                // desktop build; the web scroll wasn't smoothed so it looked fine).
183                // On non-scroll frames these are harmless no-ops. The ScrollArea
184                // reads the smoothed delta in its epilogue (after this content), so
185                // zeroing here blocks it; we also drop the wheel events.
186                ui.input_mut(|i| {
187                    i.smooth_scroll_delta = egui::Vec2::ZERO;
188                    i.events
189                        .retain(|e| !matches!(e, egui::Event::MouseWheel { .. }));
190                });
191                if notches != 0.0 {
192                    if let Some(stepped) = scroll_step_scalar(&buf, notches, *step) {
193                        buf = stepped;
194                        set_at(current, path, scalar_store(&buf));
195                        changed = true;
196                    }
197                }
198            }
199            if r.lost_focus() {
200                // Commit on Enter / click-away. Skip an EMPTY buffer (a `String("")`
201                // is a guaranteed kernel eval error) and a NO-OP (compare by the
202                // DISPLAYED text so a whole-float `20.0` vs a typed `20` — same
203                // display — doesn't re-run the history for nothing).
204                let trimmed = buf.trim();
205                if !trimmed.is_empty() && trimmed != stored_text {
206                    set_at(current, path, scalar_store(&buf));
207                    changed = true;
208                }
209                ui.data_mut(|d| d.remove::<String>(buf_id));
210            } else if r.has_focus() {
211                // Keep the in-progress buffer (incl. any scroll step) across frames.
212                ui.data_mut(|d| d.insert_temp(buf_id, buf.clone()));
213            } else {
214                // Unfocused and not committing: drop any transient buffer so the
215                // next edit reseeds from the (possibly externally changed) value.
216                ui.data_mut(|d| d.remove::<String>(buf_id));
217            }
218            (changed, r.rect)
219        }
220        FieldKind::Text { read_only } => {
221            let mut s = value_at(current, path)
222                .and_then(Value::as_str)
223                .unwrap_or("")
224                .to_string();
225            if *read_only {
226                let r = ui.add_enabled(false, egui::TextEdit::singleline(&mut s));
227                (false, r.rect)
228            } else {
229                let r = ui.add(egui::TextEdit::singleline(&mut s));
230                if r.changed() {
231                    set_at(current, path, Value::String(s));
232                }
233                (r.changed(), r.rect)
234            }
235        }
236        FieldKind::Vec3 { step } => {
237            let mut v = read_vec3(value_at(current, path));
238            let mut edited = false;
239            let mut rect = egui::Rect::NOTHING;
240            // In a RIGHT-TO-LEFT row (the right-aligned tree / settings content) egui
241            // lays widgets from the right, which would show the components as z,y,x.
242            // Add them in reverse there so they still READ x, y, z left-to-right.
243            let order: [usize; 3] = if ui.layout().prefer_right_to_left() {
244                [2, 1, 0]
245            } else {
246                [0, 1, 2]
247            };
248            for &i in &order {
249                let r = ui.add(egui::DragValue::new(&mut v[i]).speed(*step));
250                edited |= r.changed();
251                rect = rect.union(r.rect);
252            }
253            if edited {
254                set_at(current, path, serde_json::json!([v[0], v[1], v[2]]));
255            }
256            (edited, rect)
257        }
258        FieldKind::Button { label } => {
259            // An action button binds to no value; a click is surfaced via `clicked`
260            // (set to the field key) so the host — the history tree, which holds
261            // `&mut EngineState` — can act on it (e.g. `editSketch` → sketch mode).
262            let r = ui.add(egui::Button::new(label.as_str()));
263            if r.clicked() {
264                *clicked = Some(field.key().to_string());
265            }
266            (false, r.rect)
267        }
268        FieldKind::Reference { filter, multiple } => {
269            // The real engine-native picker draws here when a renderer is wired
270            // (the feature dialog / history tree, which hold `&mut EngineState`);
271            // the settings panel — which never has reference fields — passes
272            // `None` and gets the inert placeholder below.
273            if let Some(renderer) = ref_renderer {
274                renderer.render_reference(ui, field, filter, *multiple, current, probe)
275            } else {
276                ui.vertical(|ui| {
277                    ui.label(&field.label);
278                    let hint = format!(
279                        "▣ selection ({}{})",
280                        filter.join("/"),
281                        if *multiple { ", multiple" } else { "" }
282                    );
283                    let r = ui.add_enabled(false, egui::Button::new(hint));
284                    for name in reference_names(value_at(current, path)) {
285                        ui.add_enabled(false, egui::Label::new(format!("  • {name}")));
286                    }
287                    (false, r.rect)
288                })
289                .inner
290            }
291        }
292    }
293}
294
295// --- nested JSON read / write ------------------------------------------------
296
297/// Resolve `path` (object keys) to a value inside `root`, if present.
298pub(crate) fn value_at<'a>(root: &'a Value, path: &[String]) -> Option<&'a Value> {
299    let mut cur = root;
300    for seg in path {
301        cur = cur.get(seg.as_str())?;
302    }
303    Some(cur)
304}
305
306/// Write `new_val` into `root` at `path`, auto-vivifying intermediate objects
307/// (so a feature whose `inputParams` omits `transform` still accepts an edit).
308pub(crate) fn set_at(root: &mut Value, path: &[String], new_val: Value) {
309    if path.is_empty() {
310        *root = new_val;
311        return;
312    }
313    if !root.is_object() {
314        *root = Value::Object(serde_json::Map::new());
315    }
316    let mut cur = root;
317    for seg in &path[..path.len() - 1] {
318        let obj = cur.as_object_mut().expect("object by construction");
319        cur = obj
320            .entry(seg.clone())
321            .or_insert_with(|| Value::Object(serde_json::Map::new()));
322        if !cur.is_object() {
323            *cur = Value::Object(serde_json::Map::new());
324        }
325    }
326    cur.as_object_mut()
327        .expect("object by construction")
328        .insert(path[path.len() - 1].clone(), new_val);
329}
330
331// --- Scalar (expression-capable feature number) helpers ----------------------
332
333/// The text to SHOW for a [`FieldKind::Scalar`] field: a stored NUMBER as its
334/// shortest decimal string (`20`, not `20.0`; `20.5` as `20.5` — no precision
335/// loss), a stored EXPRESSION string VERBATIM (`width * 2`, never clobbered to
336/// `0`), and a missing / other value as empty.
337fn scalar_display(value: Option<&Value>) -> String {
338    match value {
339        Some(Value::String(s)) => s.clone(),
340        // Rust's `f64` Display is the shortest round-tripping form and omits a
341        // trailing `.0`, so an integer OR whole-float number both show as `20`.
342        Some(Value::Number(n)) => n.as_f64().map(|f| f.to_string()).unwrap_or_default(),
343        _ => String::new(),
344    }
345}
346
347/// Turn committed field text into the stored param [`Value`]: a PURE numeric
348/// literal becomes a JSON `Number` (clean serialization + the kernel's fast
349/// `as_f64` path), anything else becomes a `Value::String` the kernel evaluates
350/// against the history `expressions` sheet (`ctx.number` at
351/// `feature_pipeline/mod.rs`: a `String` param is `env.eval`'d). Numeric-ness is
352/// decided by a strict JSON number parse, so `10.` / `1e` and other half-typed
353/// forms stay strings rather than round-tripping through a reformat.
354fn scalar_store(text: &str) -> Value {
355    let trimmed = text.trim();
356    match serde_json::from_str::<Value>(trimmed) {
357        Ok(v @ Value::Number(_)) => v,
358        _ => Value::String(trimmed.to_string()),
359    }
360}
361
362/// Apply `notches` mouse-wheel steps of size `step` to a Scalar field's text.
363/// Only a PURE numeric literal steps (wheel up = `+step`); a non-numeric
364/// expression (`width * 2`) is un-steppable and yields `None` (a no-op — the
365/// caller leaves the text alone so typing keeps working).
366fn scroll_step_scalar(text: &str, notches: f64, step: f64) -> Option<String> {
367    let base: f64 = text.trim().parse().ok()?;
368    Some(format_scalar_number(base + notches * step, step))
369}
370
371/// Format a stepped number to the STEP's decimal precision so repeated steps do
372/// not accumulate binary-float noise (`0.1` steps stay `10.1`, `10.2`, … not
373/// `10.299999`). Trailing zeros / dot are trimmed (`10.50 → 10.5`, `9.0 → 9`).
374fn format_scalar_number(v: f64, step: f64) -> String {
375    let decimals = step_decimals(step);
376    let mut s = format!("{:.*}", decimals, v);
377    if s.contains('.') {
378        while s.ends_with('0') {
379            s.pop();
380        }
381        if s.ends_with('.') {
382            s.pop();
383        }
384    }
385    s
386}
387
388/// Decimal places implied by `step` (`0.5 → 1`, `0.1 → 1`, `0.01 → 2`, `1 → 0`),
389/// capped so a pathological step can't ask for absurd precision.
390fn step_decimals(step: f64) -> usize {
391    let step = step.abs();
392    if step == 0.0 || !step.is_finite() {
393        return 3;
394    }
395    let mut d = 0usize;
396    let mut s = step;
397    while (s - s.round()).abs() > 1e-9 && d < 6 {
398        s *= 10.0;
399        d += 1;
400    }
401    d
402}
403
404/// Whole mouse-wheel notches scrolled this frame (wheel up = `+`), normalized the
405/// SAME way the viewport's `raw_wheel_delta_y` does (Line = 1 notch, Point ÷ 40,
406/// Page × 20) and rounded to an integer notch count. Read from the raw
407/// `MouseWheel` events (not `smooth_scroll_delta`) so one physical notch is one
408/// discrete step. The caller gates this on the field being focused.
409fn wheel_notches(ui: &egui::Ui) -> f64 {
410    const LINE_POINTS: f32 = 40.0;
411    let raw: f32 = ui.input(|i| {
412        i.events
413            .iter()
414            .filter_map(|event| match event {
415                egui::Event::MouseWheel { unit, delta, .. } => Some(match unit {
416                    egui::MouseWheelUnit::Line => delta.y,
417                    egui::MouseWheelUnit::Point => delta.y / LINE_POINTS,
418                    egui::MouseWheelUnit::Page => delta.y * 20.0,
419                }),
420                _ => None,
421            })
422            .sum()
423    });
424    (raw as f64).round()
425}
426
427/// Read a 3-element numeric array (missing entries → 0).
428fn read_vec3(value: Option<&Value>) -> [f64; 3] {
429    let mut out = [0.0; 3];
430    if let Some(Value::Array(items)) = value {
431        for (i, slot) in out.iter_mut().enumerate() {
432            if let Some(n) = items.get(i).and_then(Value::as_f64) {
433                *slot = n;
434            }
435        }
436    }
437    out
438}
439
440/// Names currently held by a reference field: an array of strings/`{name}`
441/// objects, or a single string.
442pub(crate) fn reference_names(value: Option<&Value>) -> Vec<String> {
443    match value {
444        Some(Value::Array(items)) => items.iter().filter_map(name_of).collect(),
445        Some(other) => name_of(other).into_iter().collect(),
446        None => Vec::new(),
447    }
448}
449
450fn name_of(value: &Value) -> Option<String> {
451    let raw = match value {
452        Value::String(s) => Some(s.as_str()),
453        Value::Object(map) => map.get("name").and_then(Value::as_str),
454        _ => None,
455    }?;
456    let t = raw.trim();
457    (!t.is_empty()).then(|| t.to_string())
458}
459
460/// Read a `#rrggbb` value as `[u8; 3]` for `color_edit_button_srgb`.
461fn read_rgb(value: Option<&Value>) -> [u8; 3] {
462    let hex = value.and_then(Value::as_str).unwrap_or("#000000");
463    let rgb = parse_css_hex(hex).unwrap_or([0.0, 0.0, 0.0]);
464    [
465        (rgb[0] * 255.0).round() as u8,
466        (rgb[1] * 255.0).round() as u8,
467        (rgb[2] * 255.0).round() as u8,
468    ]
469}
470
471fn rgb_to_hex(rgb: [u8; 3]) -> String {
472    format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use serde_json::json;
479
480    /// A stored EXPRESSION string must render VERBATIM — the regression this fix
481    /// targets was `as_f64().unwrap_or(0.0)` silently showing `0` for a string
482    /// param and then clobbering it on the next edit.
483    #[test]
484    fn scalar_display_shows_expression_verbatim_not_zero() {
485        assert_eq!(scalar_display(Some(&json!("width * 2"))), "width * 2");
486        assert_eq!(scalar_display(Some(&json!("boxW"))), "boxW");
487        assert_eq!(scalar_display(Some(&json!("10 + 5"))), "10 + 5");
488    }
489
490    /// A stored NUMBER renders as its shortest decimal string — an integer, a
491    /// whole float, and a fractional value all round-trip cleanly (no `20.0`).
492    #[test]
493    fn scalar_display_shows_number_shortest() {
494        assert_eq!(scalar_display(Some(&json!(20))), "20");
495        assert_eq!(scalar_display(Some(&json!(20.0))), "20");
496        assert_eq!(scalar_display(Some(&json!(20.5))), "20.5");
497        assert_eq!(scalar_display(Some(&json!(0.1))), "0.1");
498        assert_eq!(scalar_display(None), "");
499        // A non-number / non-string (shouldn't occur) is shown empty, not panicked.
500        assert_eq!(scalar_display(Some(&json!(true))), "");
501    }
502
503    /// The storage contract: a pure numeric literal is stored as a JSON `Number`
504    /// (the kernel's fast path); anything else — a variable name or inline math —
505    /// is stored as a `Value::String` the kernel evaluates against `expressions`.
506    #[test]
507    fn scalar_store_number_vs_string_contract() {
508        assert_eq!(scalar_store("10"), json!(10));
509        assert_eq!(scalar_store("  20  "), json!(20));
510        assert_eq!(scalar_store("10.5"), json!(10.5));
511        assert!(scalar_store("10").is_number());
512        assert_eq!(scalar_store("width * 2"), json!("width * 2"));
513        assert_eq!(scalar_store("10 + 5"), json!("10 + 5"));
514        assert_eq!(scalar_store("boxW"), json!("boxW"));
515        // Trimmed on the string path too, so whitespace doesn't leak into the DSL.
516        assert_eq!(scalar_store("  width * 2  "), json!("width * 2"));
517    }
518
519    /// Store → display round-trips: an expression stays itself, a number shows its
520    /// shortest form.
521    #[test]
522    fn scalar_store_display_round_trip() {
523        assert_eq!(scalar_display(Some(&scalar_store("width * 2"))), "width * 2");
524        assert_eq!(scalar_display(Some(&scalar_store("10"))), "10");
525        assert_eq!(scalar_display(Some(&scalar_store("10.5"))), "10.5");
526    }
527
528    /// Scroll-stepping: a pure number steps by ±`step` (scaled by notch count) and
529    /// stays free of float noise; an expression is untouched (`None`).
530    #[test]
531    fn scroll_step_numeric_and_expression() {
532        // wheel up = +step, wheel down = -step, scaled by notches.
533        assert_eq!(scroll_step_scalar("10", 1.0, 0.5).as_deref(), Some("10.5"));
534        assert_eq!(scroll_step_scalar("10", -2.0, 0.5).as_deref(), Some("9"));
535        assert_eq!(scroll_step_scalar("10.5", 1.0, 0.5).as_deref(), Some("11"));
536        // An expression can't be stepped — leave it alone.
537        assert_eq!(scroll_step_scalar("width * 2", 1.0, 0.5), None);
538        assert_eq!(scroll_step_scalar("10 + 5", 3.0, 0.5), None);
539        assert_eq!(scroll_step_scalar("", 1.0, 0.5), None);
540    }
541
542    /// A `0.1` step must not accumulate binary-float noise: `10 + 3*0.1` is
543    /// `10.299999…` in `f64`, but the field shows `10.3`.
544    #[test]
545    fn scroll_step_kills_float_noise() {
546        assert_eq!(scroll_step_scalar("10", 3.0, 0.1).as_deref(), Some("10.3"));
547        assert_eq!(scroll_step_scalar("0", 7.0, 0.1).as_deref(), Some("0.7"));
548        // A unit step keeps integers integral.
549        assert_eq!(scroll_step_scalar("5", 2.0, 1.0).as_deref(), Some("7"));
550    }
551
552    #[test]
553    fn step_decimals_matches_step_precision() {
554        assert_eq!(step_decimals(1.0), 0);
555        assert_eq!(step_decimals(0.5), 1);
556        assert_eq!(step_decimals(0.1), 1);
557        assert_eq!(step_decimals(0.01), 2);
558    }
559
560    // --- end-to-end through a real (headless) egui frame ---------------------
561
562    use eframe::egui;
563
564    fn scalar_field() -> FormField {
565        FormField {
566            path: vec!["distance".to_string()],
567            label: "Distance".to_string(),
568            group: "Parameters".to_string(),
569            kind: FieldKind::Scalar { step: 0.5 },
570        }
571    }
572
573    /// Run ONE headless egui frame that renders the Scalar `field` bound to
574    /// `current`, feeding `events` as this frame's input. Returns `(changed, rect)`.
575    fn run_scalar_frame(
576        ctx: &egui::Context,
577        field: &FormField,
578        current: &mut Value,
579        events: Vec<egui::Event>,
580    ) -> (bool, egui::Rect) {
581        let raw = egui::RawInput {
582            screen_rect: Some(egui::Rect::from_min_size(
583                egui::pos2(0.0, 0.0),
584                egui::vec2(400.0, 300.0),
585            )),
586            events,
587            ..Default::default()
588        };
589        let mut out = (false, egui::Rect::NOTHING);
590        let _ = ctx.run_ui(raw, |ui| {
591            let (ch, r) = field_input(ui, field, current, None, None, &mut None);
592            out = (ch, r);
593        });
594        out
595    }
596
597    /// Merely RENDERING a Scalar bound to an expression string must NOT mutate it —
598    /// the regression was the number-only widget reading `None` for a string param
599    /// and writing `0` back. A passive frame reports no change and leaves the
600    /// expression intact.
601    #[test]
602    fn scalar_field_render_does_not_clobber_string_param() {
603        let ctx = egui::Context::default();
604        let field = scalar_field();
605        let mut current = serde_json::json!({ "distance": "width * 2" });
606        let (changed, _) = run_scalar_frame(&ctx, &field, &mut current, vec![]);
607        assert!(!changed, "passive render must not report a change");
608        assert_eq!(
609            current["distance"],
610            serde_json::json!("width * 2"),
611            "the expression must survive a render untouched (not clobbered to 0)"
612        );
613        // A numeric param is likewise untouched by a passive render.
614        let mut num = serde_json::json!({ "distance": 20.0 });
615        let (changed, _) = run_scalar_frame(&ctx, &field, &mut num, vec![]);
616        assert!(!changed);
617        assert_eq!(num["distance"], serde_json::json!(20.0));
618    }
619
620    /// Drive the full widget: click to focus an (empty) Scalar field, type `50`,
621    /// press Enter — the committed value reaches `current` as a usable JSON number,
622    /// through the real egui TextEdit + the commit-on-focus-loss path.
623    #[test]
624    fn scalar_field_typed_number_commits_on_enter() {
625        let ctx = egui::Context::default();
626        let field = scalar_field();
627        let mut current = serde_json::json!({});
628
629        // Frame 1: lay out, capture the field rect.
630        let (_, rect) = run_scalar_frame(&ctx, &field, &mut current, vec![]);
631        let pos = rect.center();
632
633        // Frame 2/3: click (press then release) to focus the TextEdit.
634        run_scalar_frame(
635            &ctx,
636            &field,
637            &mut current,
638            vec![
639                egui::Event::PointerMoved(pos),
640                egui::Event::PointerButton {
641                    pos,
642                    button: egui::PointerButton::Primary,
643                    pressed: true,
644                    modifiers: egui::Modifiers::default(),
645                },
646            ],
647        );
648        run_scalar_frame(
649            &ctx,
650            &field,
651            &mut current,
652            vec![egui::Event::PointerButton {
653                pos,
654                button: egui::PointerButton::Primary,
655                pressed: false,
656                modifiers: egui::Modifiers::default(),
657            }],
658        );
659
660        // Frame 4: type "50" into the focused field.
661        run_scalar_frame(
662            &ctx,
663            &field,
664            &mut current,
665            vec![egui::Event::Text("50".to_string())],
666        );
667
668        // Frame 5: Enter commits (singleline surrenders focus → lost_focus).
669        let (changed, _) = run_scalar_frame(
670            &ctx,
671            &field,
672            &mut current,
673            vec![egui::Event::Key {
674                key: egui::Key::Enter,
675                physical_key: None,
676                pressed: true,
677                repeat: false,
678                modifiers: egui::Modifiers::default(),
679            }],
680        );
681
682        assert!(changed, "committing a typed number must report a change");
683        assert_eq!(
684            current["distance"],
685            serde_json::json!(50),
686            "a typed plain number must be stored as a usable JSON number"
687        );
688        assert!(
689            current["distance"].is_number(),
690            "a pure numeric literal is stored as a Number, not a String"
691        );
692    }
693}