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}