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