Files
OneCAD-Simulation/crates/ui-egui/src/form.rs
T
jyvikoandClaude Opus 5.5 217628ead4 ui: command forms drawn from the parameter schema
The context panel's form shows typed fields instead of the JSON text: numbers
with the model's units, drop-downs of choices and of the model's sets,
materials, steps and bodies, checkboxes, vectors, groups and one-of variants
(a load's type switches its fields), lists of rows. Defaults are filled in,
required fields are marked, a value the schema finds wrong is noted under its
field, and OK waits while a field holds text that is not a value. The JSON
text stays as the Advanced view. fields.rs holds the generic widgets (row,
number, drop-down, text), adapted from SolidCraft's dialog rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 13:20:04 -04:00

455 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A command's form drawn from its parameter schema ([`fieldcraft_engine::schema`]): typed fields
//! with units, drop-downs of the model's items, groups and one-of variants. The form edits the
//! command's JSON parameters in place; it never builds anything the command does not take.
use std::collections::BTreeMap;
use egui::{Id, RichText, Ui};
use fieldcraft_engine::schema::{self, Collection, Field, Kind, MAX_DEPTH, MAX_ROWS, join};
use fieldcraft_model::UnitSystem;
use serde_json::{Map, Value, json};
use crate::fields::{self, NumberEdit, Style};
use crate::theme::Tokens;
/// The label column.
const LABEL_W: f32 = 112.0;
/// A field without its unit.
const FIELD_W: f32 = 120.0;
/// Indentation of a group's fields.
const INDENT: f32 = 10.0;
/// What the form reads beside the parameters.
pub struct Context<'a> {
pub units: UnitSystem,
/// The names of a collection's items.
pub names: &'a dyn Fn(Collection) -> Vec<String>,
/// A salt for the widget ids (the command).
pub salt: &'a str,
}
/// What a frame of the form did.
#[derive(Debug, Default)]
pub struct Outcome {
pub changed: bool,
/// Fields whose typed text is not a value yet (OK waits for them).
pub invalid: usize,
}
struct Form<'a> {
cx: &'a Context<'a>,
style: Style,
problems: BTreeMap<String, String>,
names: BTreeMap<&'static str, Vec<String>>,
out: Outcome,
}
pub fn style(t: &Tokens) -> Style {
Style { label_width: LABEL_W, field_width: FIELD_W, dim: t.text_dim, error: t.error, required: t.accent }
}
/// Draw `fields` editing `params` (an object).
pub fn show(ui: &mut Ui, t: &Tokens, cx: &Context, fields: &[Field], params: &mut Map<String, Value>) -> Outcome {
let problems = schema::object_problems(fields, params).into_iter().filter(|p| p.message != "required").map(|p| (p.path, p.message)).collect();
let mut f = Form { cx, style: style(t), problems, names: BTreeMap::new(), out: Outcome::default() };
ui.spacing_mut().item_spacing.y = 3.0;
f.fields(ui, fields, fields, params, "", 0);
f.out
}
impl Form<'_> {
fn id(&self, path: &str) -> Id {
Id::new(("fc_form", self.cx.salt, path))
}
/// A value the fields cannot show: it stays as it is, editable in the JSON view.
fn json_only(&self, ui: &mut Ui) {
ui.label(RichText::new("set in JSON").small().color(self.style.dim)).on_hover_text("Edit it in the JSON view");
}
fn names(&mut self, c: Collection) -> &[String] {
self.names.entry(c.label()).or_insert_with(|| (self.cx.names)(c))
}
fn unit(&self, dim: schema::Dim) -> Option<String> {
dim.unit(self.cx.units)
}
/// The fields of one object. `common` are the fields beside a flattened variant (kept when it
/// switches).
fn fields(&mut self, ui: &mut Ui, fields: &[Field], common: &[Field], obj: &mut Map<String, Value>, prefix: &str, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for f in fields {
if let Kind::Variants { tag, variants } = f.kind
&& f.key.is_empty()
{
let current = obj.get(tag).and_then(Value::as_str).map(str::to_string);
if let Some(t) = self.variant_choice(ui, f, variants, current.as_deref(), &join(prefix, tag)) {
*obj = schema::switch_variant(f, common, obj, &t);
self.out.changed = true;
}
if let Some(v) = obj.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
self.fields(ui, v.fields, common, obj, prefix, depth + 1);
}
continue;
}
self.field(ui, f, obj, prefix, depth);
}
}
/// A Type row: the variant's drop-down. Returns the tag picked.
fn variant_choice(&mut self, ui: &mut Ui, f: &Field, variants: &[schema::Variant], current: Option<&str>, path: &str) -> Option<String> {
let selected = variants.iter().find(|v| Some(v.tag) == current).map_or(current.unwrap_or("—"), |v| v.label);
let labels: Vec<&str> = variants.iter().map(|v| v.label).collect();
let id = self.id(path);
let picked = fields::row(ui, &self.style, f.label, f.required, |ui| fields::combo(ui, id, selected, &labels, FIELD_W + 40.0));
picked.and_then(|i| variants.get(i)).map(|v| v.tag.to_string()).filter(|t| Some(t.as_str()) != current)
}
fn field(&mut self, ui: &mut Ui, f: &Field, obj: &mut Map<String, Value>, prefix: &str, depth: usize) {
let path = join(prefix, f.key);
match f.kind {
Kind::Group(_) | Kind::Variants { .. } | Kind::Rows { .. } => self.section(ui, f, obj, &path, depth),
_ => {
let value = obj.get(f.key).cloned();
let style = self.style;
let new = fields::row(ui, &style, f.label, f.required, |ui| self.scalar(ui, f, value.as_ref(), &path));
if let Some(new) = new {
match new {
Value::Null => obj.remove(f.key),
v => obj.insert(f.key.into(), v),
};
self.out.changed = true;
}
if let Kind::Either(options) = f.kind {
self.either_value(ui, f, options, obj, &path);
}
}
}
if let Some(message) = self.problems.get(&path).cloned() {
fields::note(ui, &self.style, &message, self.style.error);
}
}
/// A one-row field; returns its new value (`Null` removes it).
fn scalar(&mut self, ui: &mut Ui, f: &Field, value: Option<&Value>, path: &str) -> Option<Value> {
let id = self.id(path);
let s = self.style;
match f.kind {
Kind::Number { dim, .. } => {
let unit = self.unit(dim);
let out = self.number(ui, id, value, f.hint, false, FIELD_W);
fields::unit(ui, &s, unit.as_deref());
out
}
Kind::Integer { .. } => self.number(ui, id, value, f.hint, true, FIELD_W),
Kind::Text | Kind::Path { .. } => {
let mut text = value.and_then(Value::as_str).unwrap_or_default().to_string();
fields::text(ui, &s, &mut text, f.hint, FIELD_W + 40.0)
.then(|| if text.is_empty() && !f.required { Value::Null } else { json!(text) })
}
Kind::Bool => {
let mut on = value.and_then(Value::as_bool).unwrap_or(false);
let r = ui.checkbox(&mut on, "");
if !f.hint.is_empty() {
r.clone().on_hover_text(f.hint);
}
r.changed().then(|| json!(on))
}
Kind::Choice(options) => {
let current = value.and_then(Value::as_str);
let selected = options
.iter()
.find(|(v, _)| Some(*v) == current)
.map_or(current.unwrap_or(if f.hint.is_empty() { "—" } else { f.hint }), |(_, l)| *l);
let mut labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
let optional = !f.required;
if optional {
labels.push("(default)");
}
fields::combo(ui, id, selected, &labels, FIELD_W + 40.0).map(|i| options.get(i).map_or(Value::Null, |(v, _)| json!(v)))
}
Kind::Vector { dim, labels, nullable } => self.vector(ui, id, value, dim, labels, nullable),
Kind::Ref(c) => self.reference(ui, id, value, c, f.hint, f.required),
Kind::RefList(c) => self.reference_list(ui, value, c, f.hint),
Kind::Either(options) => {
let key = match value {
Some(Value::String(s)) => Some(s.as_str()),
Some(Value::Object(o)) => o.keys().next().map(String::as_str),
_ => None,
};
let selected = options.iter().find(|o| Some(o.key) == key).map_or(if f.hint.is_empty() { "—" } else { f.hint }, |o| o.label);
let mut labels: Vec<&str> = options.iter().map(|o| o.label).collect();
if !f.required {
labels.push("(none)");
}
fields::combo(ui, id, selected, &labels, FIELD_W + 40.0).and_then(|i| match options.get(i) {
Some(o) if Some(o.key) == key => None,
Some(o) if matches!(o.kind, Kind::Unit) => Some(json!(o.key)),
Some(o) => Some(json!({o.key: schema::prefill(&[*o], &json!({})).get(o.key).cloned().unwrap_or(Value::Null)})),
None => Some(Value::Null),
})
}
Kind::Json => self.json(ui, id, value),
Kind::Unit | Kind::Group(_) | Kind::Variants { .. } | Kind::Rows { .. } => None,
}
}
/// The chosen option's value of an [`Kind::Either`] field, on its own row.
fn either_value(&mut self, ui: &mut Ui, f: &Field, options: &[Field], obj: &mut Map<String, Value>, path: &str) {
let Some(Value::Object(inner)) = obj.get(f.key).cloned() else { return };
let Some((key, value)) = inner.into_iter().next() else { return };
let Some(option) = options.iter().find(|o| o.key == key) else { return };
let sub = join(path, &key);
let style = self.style;
let new = fields::row(ui, &style, "", false, |ui| self.scalar(ui, option, Some(&value), &sub));
if let Some(new) = new {
obj.insert(f.key.into(), json!({key: new}));
self.out.changed = true;
}
}
fn number(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, hint: &str, integer: bool, width: f32) -> Option<Value> {
let current = value.and_then(Value::as_f64);
if value.is_some_and(|v| !v.is_null() && current.is_none()) {
self.json_only(ui);
return None;
}
match fields::number(ui, &self.style, id, current, hint, integer, width) {
NumberEdit::Set(Some(x)) if integer => Some(json!(x as i64)),
NumberEdit::Set(Some(x)) => Some(json!(x)),
NumberEdit::Set(None) => Some(Value::Null),
NumberEdit::Invalid => {
self.out.invalid += 1;
None
}
NumberEdit::Unchanged => None,
}
}
fn vector(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, dim: schema::Dim, labels: &[&str], nullable: bool) -> Option<Value> {
let current: Vec<Value> = match value {
Some(Value::Array(a)) if a.len() == labels.len() => a.clone(),
Some(v) if !v.is_null() => {
self.json_only(ui);
return None;
}
_ => vec![if nullable { Value::Null } else { json!(0.0) }; labels.len()],
};
let mut next = current.clone();
let mut changed = false;
let width = if labels.len() > 3 { 34.0 } else { 44.0 };
let unit = self.unit(dim);
ui.vertical(|ui| {
for chunk in (0..labels.len()).collect::<Vec<_>>().chunks(3) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
for &i in chunk {
let hint = labels.get(i).copied().unwrap_or("");
let slot = current.get(i).cloned().unwrap_or(Value::Null);
let shown = if nullable && slot.is_null() { None } else { Some(&slot) };
if let Some(v) = self.number(ui, id.with(i), shown, hint, false, width) {
if let Some(n) = next.get_mut(i) {
*n = if v.is_null() && !nullable { json!(0.0) } else { v };
}
changed = true;
}
}
if chunk.first() == Some(&0) {
fields::unit(ui, &self.style, unit.as_deref());
}
});
}
});
// A vector left empty is not written until edited (a required one shows as missing).
changed.then_some(Value::Array(next))
}
fn reference(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, c: Collection, hint: &str, required: bool) -> Option<Value> {
let current = value.and_then(Value::as_str).map(str::to_string);
let names = self.names(c).to_vec();
if names.is_empty() {
let mut text = current.unwrap_or_default();
let hint = format!("no {} yet", c.label());
return fields::text(ui, &self.style, &mut text, &hint, FIELD_W + 40.0).then(|| if text.is_empty() { Value::Null } else { json!(text) });
}
let empty = if hint.is_empty() { "—" } else { hint };
let selected = current.clone().unwrap_or_else(|| empty.to_string());
let mut labels: Vec<&str> = names.iter().map(String::as_str).collect();
if !required {
labels.push("(default)");
}
fields::combo(ui, id, &selected, &labels, FIELD_W + 40.0).map(|i| names.get(i).map_or(Value::Null, |n| json!(n)))
}
fn reference_list(&mut self, ui: &mut Ui, value: Option<&Value>, c: Collection, hint: &str) -> Option<Value> {
let chosen: Vec<String> =
value.and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
let names = self.names(c).to_vec();
let mut changed = None;
ui.vertical(|ui| {
if names.is_empty() {
let text = if hint.is_empty() { format!("no {} yet", c.label()) } else { hint.to_string() };
ui.label(RichText::new(text).color(self.style.dim));
}
for n in names.iter().take(MAX_ROWS) {
let mut on = chosen.contains(n);
if ui.checkbox(&mut on, n).changed() {
let mut next: Vec<String> = names.iter().filter(|m| if *m == n { on } else { chosen.contains(m) }).cloned().collect();
next.dedup();
changed = Some(if next.is_empty() { Value::Null } else { json!(next) });
}
}
});
changed
}
/// A JSON value typed as text: kept while it does not parse.
fn json(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>) -> Option<Value> {
let shown = value.map(Value::to_string).unwrap_or_default();
let mut text = ui.data(|d| d.get_temp::<(String, String)>(id)).filter(|(_, over)| *over == shown).map_or_else(|| shown.clone(), |(t, _)| t);
let bad = !text.trim().is_empty() && serde_json::from_str::<Value>(&text).is_err();
let mut edit = egui::TextEdit::singleline(&mut text).code_editor().desired_width(FIELD_W + 40.0);
if bad {
edit = edit.text_color(self.style.error);
self.out.invalid += 1;
}
let r = ui.add(edit);
let mut out = None;
if r.changed() {
if text.trim().is_empty() {
out = Some(Value::Null);
} else if let Ok(v) = serde_json::from_str::<Value>(&text) {
out = Some(v);
}
}
let over = out.as_ref().map_or(shown, |v| if v.is_null() { String::new() } else { v.to_string() });
ui.data_mut(|d| d.insert_temp(id, (text, over)));
out
}
/// A group, a keyed variant or a list of rows: a sub-heading (with a checkbox when optional),
/// then its fields, indented.
fn section(&mut self, ui: &mut Ui, f: &Field, obj: &mut Map<String, Value>, path: &str, depth: usize) {
let present = obj.get(f.key).is_some_and(|v| !v.is_null());
let mut on = present;
ui.add_space(2.0);
if f.required {
ui.label(RichText::new(f.label).strong());
} else {
ui.checkbox(&mut on, RichText::new(f.label).strong());
}
if !f.hint.is_empty() && !present {
fields::note(ui, &Style { label_width: INDENT, ..self.style }, f.hint, self.style.dim);
}
if on != present {
if on {
let filled = schema::prefill(&[Field { required: true, ..*f }], &json!({}));
let start = filled.get(f.key).cloned().unwrap_or_else(|| if matches!(f.kind, Kind::Rows { .. }) { json!([]) } else { json!({}) });
obj.insert(f.key.into(), start);
} else {
obj.remove(f.key);
}
self.out.changed = true;
}
if !on {
return;
}
ui.horizontal(|ui| {
ui.add_space(INDENT);
ui.vertical(|ui| match f.kind {
Kind::Group(fields) => {
if let Some(Value::Object(child)) = obj.get_mut(f.key) {
self.fields(ui, fields, fields, child, path, depth + 1);
}
}
Kind::Variants { tag, variants } => {
let Some(Value::Object(child)) = obj.get_mut(f.key) else {
self.json_only(ui);
return;
};
let current = child.get(tag).and_then(Value::as_str).map(str::to_string);
let row = Field { label: "Type", required: true, ..*f };
if let Some(t) = self.variant_choice(ui, &row, variants, current.as_deref(), &join(path, tag)) {
*child = schema::switch_variant(f, &[], child, &t);
self.out.changed = true;
}
if let Some(v) = child.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
self.fields(ui, v.fields, v.fields, child, path, depth + 1);
}
}
Kind::Rows { fields, max } => self.rows(ui, fields, max, obj, f.key, path, depth),
_ => {}
});
});
}
#[allow(clippy::too_many_arguments)]
fn rows(&mut self, ui: &mut Ui, fields: &[Field], max: usize, obj: &mut Map<String, Value>, key: &str, path: &str, depth: usize) {
let Some(Value::Array(list)) = obj.get_mut(key) else {
self.json_only(ui);
return;
};
let mut remove = None;
for (i, row) in list.iter_mut().enumerate().take(max) {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{}", i + 1)).small().color(self.style.dim));
if ui.small_button("×").on_hover_text("Remove").clicked() {
remove = Some(i);
}
});
if let Value::Object(child) = row {
self.fields(ui, fields, fields, child, &format!("{path}/{i}"), depth + 1);
}
}
if let Some(i) = remove {
list.remove(i);
self.out.changed = true;
}
if list.len() < max && ui.small_button("+ Add").clicked() {
list.push(schema::prefill(fields, &json!({})));
self.out.changed = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every command's form draws, with its defaults and with hostile parameters, without
/// panicking, and drawing alone changes nothing.
#[test]
fn every_form_draws() {
let ctx = egui::Context::default();
let names = |c: Collection| vec![format!("{}1", c.label())];
let hostile =
[json!({}), json!({"type": 7, "name": [], "elastic": "x", "target": {"set": 1, "reference_point": 2}, "values": [1], "geometry": {}})];
for c in fieldcraft_engine::commands() {
let Some(form) = c.form else { continue };
for start in hostile.iter().chain([&schema::prefill(form, &json!({}))]) {
let mut params = start.as_object().cloned().unwrap_or_default();
let before = params.clone();
let mut outcome = Outcome::default();
let width = crate::context::WIDTH - 16.0;
let mut used = 0.0;
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let cx = Context { units: UnitSystem::MmTonneS, names: &names, salt: c.id };
ui.vertical(|ui| {
ui.set_max_width(width);
outcome = show(ui, &Tokens::get(false), &cx, form, &mut params);
used = ui.min_rect().width();
});
});
// Wider would push the context panel over the viewport.
assert!(used <= width + 0.5, "{}: {used}", c.id);
out.textures_delta.clear();
assert!(!outcome.changed, "{}", c.id);
assert_eq!(params, before, "{}", c.id);
}
}
}
}