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