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` → a full-width activation button with the current selection
26//!     listed BENEATH it, one line per entity, each with an `✕` to remove it.
27//!     The `✕` is pinned to the line's RIGHT edge and the name truncates into
28//!     what is left (hover for the whole one) — an entity name is as long as the
29//!     modelling history made it, and a line laid out name-first would push its
30//!     own remove button off the panel.
31//!     Pressing the button is surfaced to the caller by the field key (the same
32//!     `clicked` out-param a `Button` uses) — entering the picker needs the
33//!     engine, and this file deliberately has none. HOVERING a line reports the
34//!     entity NAME on it the same way ([`FieldActions::hovered_entity`]), so the
35//!     caller can light that entity in the 3D scene.
36//!
37//! # Width, and who decides it
38//!
39//! An input fills the width it is given in a TOP-DOWN layout (the label-above
40//! form view: [`crate::form_view`]) and stays COMPACT in a RIGHT-TO-LEFT row
41//! (the tree/settings idiom, where the label is the tree node and the input sits
42//! at the panel edge). `ui.layout().prefer_right_to_left()` is the one
43//! discriminator — the same one `Vec3` has always used to order its components —
44//! so neither caller passes a layout flag and neither can drift from the other.
45//!
46//! A field binds to a `path` (a chain of JSON object keys), so it can read/write
47//! a NESTED value (`["transform","position"]`, `["boolean","operation"]`), not
48//! just a top-level key. On any change it writes back into `current` (the JSON
49//! document being edited — settings JSON, or a feature's `inputParams`) and
50//! returns `true`; the caller re-applies / re-runs. There is deliberately NO
51//! per-field code, so this file is reusable verbatim by a later `brep-ui` crate.
52
53pub(crate) use brep_render::brep_kernel::reference_names;
54use crate::color::rgb_to_hex;
55use brep_render::style::{parse_css_hex, FieldKind, FormField};
56use eframe::egui;
57use serde_json::Value;
58use std::collections::HashMap;
59
60/// The red of a reference line's remove `✕` — the destructive red the history
61/// tree's delete affordance uses (theme-independent on purpose: it must read as
62/// "removes something" in both light and dark).
63const REMOVE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);
64
65/// The width an input should take: whatever the caller has left in a TOP-DOWN
66/// form (label above, full-width field), or `compact` in a RIGHT-TO-LEFT tree
67/// row, where the input shares the row with its label.
68fn input_width(ui: &egui::Ui, compact: f32) -> f32 {
69    if ui.layout().prefer_right_to_left() {
70        compact
71    } else {
72        ui.available_width()
73    }
74}
75
76/// What the user did to a field that only the CALLER can act on — both of these
77/// need an engine (entering the reference picker; lighting an entity in the 3D
78/// scene), and this file deliberately has none.
79///
80/// ONE struct rather than two `&mut Option<String>` out-params: they carry
81/// different things (a field KEY vs an entity NAME) but the same type, so
82/// adjacent out-params would swap silently at a call site.
83#[derive(Debug, Default, Clone, PartialEq)]
84pub struct FieldActions {
85    /// A [`FieldKind::Button`] was pressed, or a `Reference`'s `Select` was — the
86    /// field KEY, because acting on either needs the engine.
87    pub clicked: Option<String>,
88    /// The pointer is over a reference LINE: the entity NAME that line lists, for
89    /// the caller to hover-highlight in the 3D scene.
90    pub hovered_entity: Option<String>,
91}
92
93/// Emit ONLY the input widget(s) for one field — NO leading label, NO forced row
94/// layout (the caller supplies both: the form view puts the label ABOVE and gives
95/// the input the full width; the settings tree puts the label in the tree node
96/// and this input in the row's right-aligned content). Writes any change back
97/// into `current` at the field's `path`, returns
98/// `(changed, interactive_widget_rect)`.
99///
100/// `probe`, when present, additionally receives each OPEN enum item's rect (keyed
101/// `"<path>#<variant>"`) and a reference field's activation, per-line and
102/// per-line-remove rects (`"<path>#activate"`, `"<path>#line<index>"`,
103/// `"<path>#x<index>"`) for the headed verifier.
104///
105/// `actions` is the out-param for what is NOT a value: a `Button` press, a
106/// `Reference`'s `Select` press, and the entity a hovered reference line names.
107pub fn field_input(
108    ui: &mut egui::Ui,
109    field: &FormField,
110    current: &mut Value,
111    mut probe: Option<&mut HashMap<String, egui::Rect>>,
112    actions: &mut FieldActions,
113) -> (bool, egui::Rect) {
114    let path = &field.path;
115    match &field.kind {
116        FieldKind::Color => {
117            let mut rgb = read_rgb(value_at(current, path));
118            let r = ui.color_edit_button_srgb(&mut rgb);
119            if r.changed() {
120                set_at(current, path, Value::String(rgb_to_hex(rgb)));
121            }
122            (r.changed(), r.rect)
123        }
124        FieldKind::Bool => {
125            let mut b = value_at(current, path).and_then(Value::as_bool).unwrap_or(false);
126            let r = ui.checkbox(&mut b, "");
127            if r.changed() {
128                set_at(current, path, Value::Bool(b));
129            }
130            (r.changed(), r.rect)
131        }
132        FieldKind::Enum { variants } => {
133            let orig = value_at(current, path)
134                .and_then(Value::as_str)
135                .unwrap_or("")
136                .to_string();
137            let mut sel = orig.clone();
138            let mut combo = egui::ComboBox::from_id_salt(("form-enum", field.key()));
139            if !ui.layout().prefer_right_to_left() {
140                combo = combo.width(ui.available_width());
141            }
142            let combo = combo
143                .selected_text(&sel)
144                .show_ui(ui, |ui| {
145                    for v in variants {
146                        let item = ui.selectable_value(&mut sel, v.to_string(), v.as_str());
147                        if let Some(map) = probe.as_deref_mut() {
148                            map.insert(format!("{}#{}", path.join("."), v), item.rect);
149                        }
150                    }
151                });
152            let changed = sel != orig;
153            if changed {
154                set_at(current, path, Value::String(sel));
155            }
156            (changed, combo.response.rect)
157        }
158        FieldKind::Number { min, max, step } | FieldKind::Range { min, max, step } => {
159            let mut v = value_at(current, path).and_then(Value::as_f64).unwrap_or(*min);
160            let r = ui.add(egui::Slider::new(&mut v, *min..=*max).step_by(*step));
161            if r.changed() {
162                set_at(current, path, serde_json::json!(v));
163            }
164            (r.changed(), r.rect)
165        }
166        FieldKind::Scalar { step } => {
167            // An EXPRESSION-CAPABLE numeric field — see [`scalar_widget`], which
168            // owns the whole behaviour so a `Vec3` component gets exactly the same
169            // field.
170            let (committed, rect) = scalar_widget(
171                ui,
172                ("scalar-edit", path.join(".")),
173                value_at(current, path),
174                *step,
175                input_width(ui, 72.0),
176            );
177            let changed = committed.is_some();
178            if let Some(value) = committed {
179                set_at(current, path, value);
180            }
181            (changed, rect)
182        }
183        FieldKind::Text { read_only } => {
184            let mut s = value_at(current, path)
185                .and_then(Value::as_str)
186                .unwrap_or("")
187                .to_string();
188            let width = input_width(ui, ui.spacing().text_edit_width);
189            if *read_only {
190                let r = ui.add_enabled(
191                    false,
192                    egui::TextEdit::singleline(&mut s).desired_width(width),
193                );
194                (false, r.rect)
195            } else {
196                let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width));
197                if r.changed() {
198                    set_at(current, path, Value::String(s));
199                }
200                (r.changed(), r.rect)
201            }
202        }
203        FieldKind::Vec3 { step } => {
204            // THREE expression-capable scalar fields, not three number-only drag
205            // values: a transform component is a numeric param like any other, so
206            // `position: ["W/2", 0, 0]` must be typeable and must SURVIVE. The
207            // number-only widget showed a stored expression as `0` and wrote all
208            // three slots back as numbers on any edit, silently destroying the
209            // other two — [`vec3_with_slot`] rewrites ONLY the edited index and
210            // keeps its siblings' JSON verbatim.
211            let stored = value_at(current, path).cloned();
212            let mut edited: Option<(usize, Value)> = None;
213            let mut rect = egui::Rect::NOTHING;
214            let mut component = |ui: &mut egui::Ui, index: usize, width: f32| {
215                let (committed, r) = scalar_widget(
216                    ui,
217                    ("vec3-edit", path.join("."), index),
218                    vec3_slot(stored.as_ref(), index),
219                    *step,
220                    width,
221                );
222                if let Some(value) = committed {
223                    edited = Some((index, value));
224                }
225                rect = rect.union(r);
226            };
227            if ui.layout().prefer_right_to_left() {
228                // In a RIGHT-TO-LEFT row (the right-aligned tree / settings content)
229                // egui lays widgets from the right, which would show the components
230                // as z,y,x. Add them in reverse so they still READ x, y, z.
231                for index in [2usize, 1, 0] {
232                    component(ui, index, 56.0);
233                }
234            } else {
235                // Label-above form: ONE row of three EQUAL-width fields under the
236                // single label, reading x, y, z. `ui.horizontal` is this arm's own —
237                // the caller's layout is vertical, so without it the three
238                // components would stack.
239                let gap = ui.spacing().item_spacing.x;
240                let each = ((ui.available_width() - 2.0 * gap) / 3.0).max(24.0);
241                ui.horizontal(|ui| {
242                    for index in 0..3 {
243                        component(ui, index, each);
244                    }
245                });
246            }
247            match edited {
248                Some((index, value)) => {
249                    set_at(current, path, vec3_with_slot(stored.as_ref(), index, value));
250                    (true, rect)
251                }
252                None => (false, rect),
253            }
254        }
255        FieldKind::Button { label } => {
256            // An action button binds to no value; a click is surfaced via `clicked`
257            // (set to the field key) so the host — the history tree, which holds
258            // `&mut EngineState` — can act on it (e.g. `editSketch` → sketch mode).
259            //
260            // FULL WIDTH, like the reference field's `Select` below: an action
261            // button is the primary thing to do in its section (Edit Sketch IS the
262            // sketch form), and a content-width button floating at the left of a
263            // full-width form reads as a minor control rather than the main one.
264            let r = ui.add_sized(
265                [ui.available_width(), ui.spacing().interact_size.y],
266                egui::Button::new(label.as_str()),
267            );
268            if r.clicked() {
269                actions.clicked = Some(field.key().to_string());
270            }
271            (false, r.rect)
272        }
273        FieldKind::Reference { filter, multiple } => {
274            // R4 — the reference widget: a full-width activation button with the
275            // chosen entities listed BENEATH it, one per line, each with an `✕`
276            // to remove it. Nothing expands: the selection is what the user needs
277            // to see, so it is never hidden behind a `[+]`.
278            //
279            // Pressing `Select` reports the field key through `clicked` (exactly
280            // as a `Button` does) — entering the modal picker is the ENGINE's job
281            // and the caller owns which flavour of picker to enter. Removing a
282            // line, by contrast, is a pure edit of `current`, so it happens here
283            // and reports `changed` like any other field.
284            let names = reference_names(value_at(current, path));
285            let pkey = path.join(".");
286            let mut removed: Option<usize> = None;
287            let mut activate_rect = egui::Rect::NOTHING;
288            ui.vertical(|ui| {
289                let width = ui.available_width();
290                let hint = format!(
291                    "▣ Select {}{}",
292                    filter.join("/"),
293                    if *multiple { " …" } else { "" }
294                );
295                let select = crate::icon_text::icon_button(ui, &hint);
296                let button = ui.add_sized(
297                    [width, ui.spacing().interact_size.y],
298                    select,
299                );
300                activate_rect = button.rect;
301                if button.clicked() {
302                    actions.clicked = Some(field.key().to_string());
303                }
304                if let Some(map) = probe.as_deref_mut() {
305                    map.insert(format!("{pkey}#activate"), button.rect);
306                }
307                if names.is_empty() {
308                    ui.label(egui::RichText::new("(none)").weak());
309                    return;
310                }
311                for (i, name) in names.iter().enumerate() {
312                    // The ✕ is allocated FIRST, from the RIGHT edge, and the name
313                    // takes what is left over — the same order the form view's
314                    // title row uses, for the same reason. An entity name is as
315                    // long as the modelling history made it (a fillet on a blend
316                    // edge reaches 76 characters), so name-then-button lets the
317                    // name push the button clean off the panel: measured 212 pt
318                    // past the edge of a 320 pt panel, where an ancestor clip
319                    // then eats the click as well as the pixels.
320                    ui.horizontal(|ui| {
321                        ui.with_layout(
322                            egui::Layout::right_to_left(egui::Align::Center),
323                            |ui| {
324                                let remove = crate::icon_text::icon_button_colored(
325                                    ui,
326                                    "✕",
327                                    Some(REMOVE_RED),
328                                )
329                                .stroke(egui::Stroke::new(1.0, REMOVE_RED))
330                                .small();
331                                let x = ui.add(remove);
332                                if let Some(map) = probe.as_deref_mut() {
333                                    map.insert(format!("{pkey}#x{i}"), x.rect);
334                                }
335                                if x.clicked() {
336                                    removed = Some(i);
337                                }
338                                // Back to reading order for the name, inside
339                                // whatever width the button left behind. It
340                                // TRUNCATES there rather than wrapping: these
341                                // names differ in their tails (`[0]` vs `[1]`,
342                                // `NZ` vs `NY`), and a `Label` that elides shows
343                                // the WHOLE text on hover by itself
344                                // (`show_tooltip_when_elided`, on by default),
345                                // so the tail is a hover away and adding an
346                                // `on_hover_text` here would only stack a second
347                                // tooltip on egui's.
348                                ui.with_layout(
349                                    egui::Layout::left_to_right(egui::Align::Center),
350                                    |ui| {
351                                        let line = ui.add(
352                                            egui::Label::new(format!("• {name}"))
353                                                .truncate(),
354                                        );
355                                        // Hovering the line reports the entity it
356                                        // names, so the caller can light it in the
357                                        // 3D scene exactly as mousing over it there
358                                        // would — a reference list is a list of
359                                        // things in the model, and reading which
360                                        // `…|BOUNDARY[1]` is which off the name
361                                        // alone is what the highlight replaces.
362                                        if line.hovered() {
363                                            actions.hovered_entity = Some(name.clone());
364                                        }
365                                        if let Some(map) = probe.as_deref_mut() {
366                                            map.insert(
367                                                format!("{pkey}#line{i}"),
368                                                line.rect,
369                                            );
370                                        }
371                                    },
372                                );
373                            },
374                        );
375                    });
376                }
377            });
378            if let Some(i) = removed {
379                let mut kept = names;
380                kept.remove(i);
381                let value = if *multiple {
382                    Value::Array(kept.into_iter().map(Value::String).collect())
383                } else {
384                    Value::String(kept.first().cloned().unwrap_or_default())
385                };
386                set_at(current, path, value);
387                return (true, activate_rect);
388            }
389            (false, activate_rect)
390        }
391    }
392}
393
394// --- nested JSON read / write ------------------------------------------------
395
396/// Resolve `path` (object keys) to a value inside `root`, if present.
397pub(crate) fn value_at<'a>(root: &'a Value, path: &[String]) -> Option<&'a Value> {
398    let mut cur = root;
399    for seg in path {
400        cur = cur.get(seg.as_str())?;
401    }
402    Some(cur)
403}
404
405/// Write `new_val` into `root` at `path`, auto-vivifying intermediate objects
406/// (so a feature whose `inputParams` omits `transform` still accepts an edit).
407pub(crate) fn set_at(root: &mut Value, path: &[String], new_val: Value) {
408    if path.is_empty() {
409        *root = new_val;
410        return;
411    }
412    if !root.is_object() {
413        *root = Value::Object(serde_json::Map::new());
414    }
415    let mut cur = root;
416    for seg in &path[..path.len() - 1] {
417        let obj = cur.as_object_mut().expect("object by construction");
418        cur = obj
419            .entry(seg.clone())
420            .or_insert_with(|| Value::Object(serde_json::Map::new()));
421        if !cur.is_object() {
422            *cur = Value::Object(serde_json::Map::new());
423        }
424    }
425    cur.as_object_mut()
426        .expect("object by construction")
427        .insert(path[path.len() - 1].clone(), new_val);
428}
429
430// --- Scalar (expression-capable feature number) helpers ----------------------
431
432/// The ONE expression-capable numeric field: a [`FieldKind::Scalar`], and each
433/// component of a [`FieldKind::Vec3`].
434///
435/// It shows a stored NUMBER as text and a stored EXPRESSION (`width * 2`)
436/// VERBATIM — never clobbering a string param to `0` the way a number-only
437/// `DragValue` does — and lets the user type either. A transient edit buffer
438/// lives in egui memory (keyed by `id_source`) and is committed on focus-loss
439/// (Enter / click-away), mirroring `panels::expressions`, so a half-typed
440/// expression (`width *`) never re-runs the history mid-keystroke. While the
441/// field is focused, the mouse wheel STEPS a pure number by `step` (recovering
442/// the old drag-value stepping); a scroll notch is a complete, valid edit, so it
443/// commits immediately.
444///
445/// Returns the value to STORE when the edit committed (the caller owns where it
446/// goes — a whole param for `Scalar`, one slot of the array for `Vec3`) and the
447/// widget rect.
448fn scalar_widget(
449    ui: &mut egui::Ui,
450    id_source: impl std::hash::Hash + std::fmt::Debug,
451    stored: Option<&Value>,
452    step: f64,
453    width: f32,
454) -> (Option<Value>, egui::Rect) {
455    let buf_id = ui.make_persistent_id(id_source);
456    let stored_text = scalar_display(stored);
457    // Seed from the live buffer while editing; otherwise from the stored value
458    // (an undo / gizmo edit may have changed it out from under us).
459    let mut buf = ui
460        .data_mut(|d| d.get_temp::<String>(buf_id))
461        .unwrap_or_else(|| stored_text.clone());
462
463    let r = ui.add(
464        egui::TextEdit::singleline(&mut buf)
465            .id(buf_id)
466            .desired_width(width),
467    );
468
469    let mut committed = None;
470    if r.gained_focus() {
471        // Start each edit from the current stored value.
472        buf = stored_text.clone();
473    }
474    // Scroll-to-step a PURE number by `step` (wheel up = +step) while the field
475    // is focused AND the pointer is over it ("scroll over the field to step
476    // it"). A non-numeric expression is un-steppable (`scroll_step_scalar` →
477    // `None`) and left untouched — typing still works. Step ONLY when the cursor
478    // is actually over this field, so a focused field doesn't swallow panel
479    // scrolling while the user scrolls elsewhere to navigate (that would
480    // silently edit the value). Two independent "pointer is over me" signals for
481    // robustness — a real wheel carries the cursor position on native/desktop;
482    // only synthetic (headless-test) wheels lack it.
483    let pointer_over_field = r.hovered()
484        || ui
485            .input(|i| i.pointer.latest_pos())
486            .is_some_and(|pos| r.rect.contains(pos));
487    if r.has_focus() && pointer_over_field {
488        let notches = wheel_notches(ui);
489        // EAT the wheel so the enclosing side-panel `ScrollArea` can't ALSO
490        // scroll the panel — the field owns the scroll while the cursor is over
491        // it. Do this EVERY frame the cursor is here, not only on the notch
492        // frame: egui SMOOTHS a wheel notch across several frames, and only the
493        // first carries a `MouseWheel` event, so zeroing just that frame let the
494        // smoothed TAIL leak into the panel (visible on the desktop build; the
495        // web scroll wasn't smoothed so it looked fine). On non-scroll frames
496        // these are harmless no-ops. The ScrollArea reads the smoothed delta in
497        // its epilogue (after this content), so zeroing here blocks it; we also
498        // drop the wheel events.
499        ui.input_mut(|i| {
500            i.smooth_scroll_delta = egui::Vec2::ZERO;
501            i.events
502                .retain(|e| !matches!(e, egui::Event::MouseWheel { .. }));
503        });
504        if notches != 0.0 {
505            if let Some(stepped) = scroll_step_scalar(&buf, notches, step) {
506                buf = stepped;
507                committed = Some(scalar_store(&buf));
508            }
509        }
510    }
511    if r.lost_focus() {
512        // Commit on Enter / click-away. Skip an EMPTY buffer (a `String("")` is a
513        // guaranteed kernel eval error) and a NO-OP (compare by the DISPLAYED
514        // text so a whole-float `20.0` vs a typed `20` — same display — doesn't
515        // re-run the history for nothing).
516        let trimmed = buf.trim();
517        if !trimmed.is_empty() && trimmed != stored_text {
518            committed = Some(scalar_store(&buf));
519        }
520        ui.data_mut(|d| d.remove::<String>(buf_id));
521    } else if r.has_focus() {
522        // Keep the in-progress buffer (incl. any scroll step) across frames.
523        ui.data_mut(|d| d.insert_temp(buf_id, buf.clone()));
524    } else {
525        // Unfocused and not committing: drop any transient buffer so the next
526        // edit reseeds from the (possibly externally changed) value.
527        ui.data_mut(|d| d.remove::<String>(buf_id));
528    }
529    (committed, r.rect)
530}
531
532/// One slot of a stored vec3 (`[x, y, z]`), or `None` when the param is absent /
533/// not an array / short. A slot is read VERBATIM — a number stays a number and an
534/// expression string stays that string, so `scalar_display` shows it as authored.
535fn vec3_slot(stored: Option<&Value>, index: usize) -> Option<&Value> {
536    stored?.as_array()?.get(index).filter(|v| !v.is_null())
537}
538
539/// The vec3 to STORE after one component was edited: a full three-element array
540/// with `index` replaced and the OTHER TWO slots carried over verbatim. Carrying
541/// them is the point — writing all three back from three coerced `f64`s is what
542/// silently turned a sibling `"W/2"` into `0`. A missing sibling materializes as
543/// `0`, which is what the number-only widget already displayed for it.
544fn vec3_with_slot(stored: Option<&Value>, index: usize, value: Value) -> Value {
545    let mut out: Vec<Value> = (0..3)
546        .map(|i| vec3_slot(stored, i).cloned().unwrap_or(Value::from(0.0)))
547        .collect();
548    out[index] = value;
549    Value::Array(out)
550}
551
552/// The text to SHOW for a [`FieldKind::Scalar`] field: a stored NUMBER as its
553/// shortest decimal string (`20`, not `20.0`; `20.5` as `20.5` — no precision
554/// loss), a stored EXPRESSION string VERBATIM (`width * 2`, never clobbered to
555/// `0`), and a missing / other value as empty.
556fn scalar_display(value: Option<&Value>) -> String {
557    match value {
558        Some(Value::String(s)) => s.clone(),
559        // Rust's `f64` Display is the shortest round-tripping form and omits a
560        // trailing `.0`, so an integer OR whole-float number both show as `20`.
561        Some(Value::Number(n)) => n.as_f64().map(|f| f.to_string()).unwrap_or_default(),
562        _ => String::new(),
563    }
564}
565
566/// Turn committed field text into the stored param [`Value`]: a PURE numeric
567/// literal becomes a JSON `Number` (clean serialization + the kernel's fast
568/// `as_f64` path), anything else becomes a `Value::String` the kernel evaluates
569/// against the history `expressions` sheet (`ctx.number` at
570/// `feature_pipeline/mod.rs`: a `String` param is `env.eval`'d). Numeric-ness is
571/// decided by a strict JSON number parse, so `10.` / `1e` and other half-typed
572/// forms stay strings rather than round-tripping through a reformat.
573fn scalar_store(text: &str) -> Value {
574    let trimmed = text.trim();
575    match serde_json::from_str::<Value>(trimmed) {
576        Ok(v @ Value::Number(_)) => v,
577        _ => Value::String(trimmed.to_string()),
578    }
579}
580
581/// Apply `notches` mouse-wheel steps of size `step` to a Scalar field's text.
582/// Only a PURE numeric literal steps (wheel up = `+step`); a non-numeric
583/// expression (`width * 2`) is un-steppable and yields `None` (a no-op — the
584/// caller leaves the text alone so typing keeps working).
585fn scroll_step_scalar(text: &str, notches: f64, step: f64) -> Option<String> {
586    let base: f64 = text.trim().parse().ok()?;
587    Some(format_scalar_number(base + notches * step, step))
588}
589
590/// Format a stepped number to the STEP's decimal precision so repeated steps do
591/// not accumulate binary-float noise (`0.1` steps stay `10.1`, `10.2`, … not
592/// `10.299999`). Trailing zeros / dot are trimmed (`10.50 → 10.5`, `9.0 → 9`).
593fn format_scalar_number(v: f64, step: f64) -> String {
594    let decimals = step_decimals(step);
595    let mut s = format!("{:.*}", decimals, v);
596    if s.contains('.') {
597        while s.ends_with('0') {
598            s.pop();
599        }
600        if s.ends_with('.') {
601            s.pop();
602        }
603    }
604    s
605}
606
607/// Decimal places implied by `step` (`0.5 → 1`, `0.1 → 1`, `0.01 → 2`, `1 → 0`),
608/// capped so a pathological step can't ask for absurd precision.
609fn step_decimals(step: f64) -> usize {
610    let step = step.abs();
611    if step == 0.0 || !step.is_finite() {
612        return 3;
613    }
614    let mut d = 0usize;
615    let mut s = step;
616    while (s - s.round()).abs() > 1e-9 && d < 6 {
617        s *= 10.0;
618        d += 1;
619    }
620    d
621}
622
623/// Whole mouse-wheel notches scrolled this frame (wheel up = `+`), rounded to an
624/// integer notch count. Read from the raw `MouseWheel` events (not
625/// `smooth_scroll_delta`) so one physical notch is one discrete step. Browser
626/// backends report a conventional wheel detent as roughly 100 pixels (Chromium)
627/// or 3 lines (Firefox), whereas native winit reports it as 40 points or 1 line;
628/// keep those platform scales separate so the web field doesn't jump by 2–3
629/// schema steps for the same wheel movement. The caller gates this on the field
630/// being focused.
631fn wheel_notches(ui: &egui::Ui) -> f64 {
632    let raw: f32 = ui.input(|i| {
633        i.events
634            .iter()
635            .filter_map(|event| match event {
636                egui::Event::MouseWheel { unit, delta, .. } => Some(wheel_delta_to_notches(
637                    *unit,
638                    delta.y,
639                    cfg!(target_arch = "wasm32"),
640                )),
641                _ => None,
642            })
643            .sum()
644    });
645    (raw as f64).round()
646}
647
648fn wheel_delta_to_notches(unit: egui::MouseWheelUnit, delta_y: f32, web: bool) -> f32 {
649    let (lines_per_notch, points_per_notch) = if web { (3.0, 100.0) } else { (1.0, 40.0) };
650    match unit {
651        egui::MouseWheelUnit::Line => delta_y / lines_per_notch,
652        egui::MouseWheelUnit::Point => delta_y / points_per_notch,
653        egui::MouseWheelUnit::Page => delta_y * 20.0,
654    }
655}
656
657/// Read a `#rrggbb` value as `[u8; 3]` for `color_edit_button_srgb`.
658fn read_rgb(value: Option<&Value>) -> [u8; 3] {
659    let hex = value.and_then(Value::as_str).unwrap_or("#000000");
660    let rgb = parse_css_hex(hex).unwrap_or([0.0, 0.0, 0.0]);
661    [
662        (rgb[0] * 255.0).round() as u8,
663        (rgb[1] * 255.0).round() as u8,
664        (rgb[2] * 255.0).round() as u8,
665    ]
666}
667
668// BREP private tests: 80a53c1ac36dcfe7