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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 13:20:04 -04:00
1 parent 8848ef9e7c
commit 217628ead4
6 files changed
+662 -7

No files matched your search

+1
View File
@@ -168,6 +168,7 @@ is committed and listed here with its exports.
| crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 |
| crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 |
| crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 |
| crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 |
| crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 |
| apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
+49 -5
View File
@@ -58,11 +58,17 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
});
}
/// The open command's parameters as JSON, with the command's parameter documentation; OK runs it.
/// JSON shorter than this is shown pretty-printed in the JSON view.
const MAX_PRETTY: usize = 100_000;
/// The open command's form: typed fields drawn from its schema (or its JSON text, the Advanced
/// view), with the command's parameter documentation; OK runs it.
/// A command that applies to part of the model (a set, a boundary condition, a load, a section, a
/// coupling) has a Target row that takes the viewport's selection.
fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
let selected = (!app.pick.selection.is_empty()).then(|| app.pick.selection.summary());
let units = app.session.model.units();
let session = &app.session;
let mut use_selection = false;
let Some(d) = app.dialog.as_mut() else { return };
let Some(spec) = fieldcraft_engine::find_command(d.command) else {
@@ -90,15 +96,47 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
};
use_selection = ui.add_enabled(selected.is_some(), egui::Button::new("Use selection")).on_hover_text(tip).clicked();
match &selected {
Some(text) => ui.label(RichText::new(text).color(t.pick_selected)),
None => ui.label(RichText::new("Pick in the viewport, or type the names below").color(t.text_dim)),
Some(text) => ui.add(egui::Label::new(RichText::new(text).color(t.pick_selected)).wrap()),
None => ui.add(egui::Label::new(RichText::new("Pick in the viewport, or choose below").color(t.text_dim)).wrap()),
};
});
}
let mut advanced = d.advanced;
heading(ui, t, Level::Section, |ui| {
ui.label(RichText::new("Parameters").size(12.0));
if spec.form.is_some() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.toggle_value(&mut advanced, RichText::new("{ } JSON").small()).on_hover_text("Advanced: edit the parameters as JSON");
});
}
});
ui.add(egui::TextEdit::multiline(&mut d.text).code_editor().desired_rows(6).desired_width(f32::INFINITY));
let mut invalid = 0;
// The typed fields edit the JSON text's object; text that is not an object stays in the
// JSON view until it is.
let object = params.as_object().cloned();
if advanced != d.advanced && !advanced && object.is_none() {
d.error = Some("fix the JSON before going back to the fields".into());
advanced = true;
}
// Pretty-printed once, when the JSON view opens.
if advanced && !d.advanced && params.is_object() && d.text.len() < MAX_PRETTY {
d.text = serde_json::to_string_pretty(&params).unwrap_or_else(|_| d.text.clone());
}
d.advanced = advanced;
match (spec.form, object) {
(Some(fields), Some(mut obj)) if !d.advanced => {
let cx = crate::form::Context { units, names: &|c| session.names(c), salt: spec.id };
let out = crate::form::show(ui, t, &cx, fields, &mut obj);
invalid = out.invalid;
if out.changed {
d.text = Value::Object(obj).to_string();
d.error = None;
}
}
_ => {
ui.add(egui::TextEdit::multiline(&mut d.text).code_editor().desired_rows(6).desired_width(f32::INFINITY));
}
}
if let Some(e) = &d.error {
ui.label(RichText::new(e).color(t.error));
}
@@ -109,7 +147,13 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
cancel |= ui.button("Cancel").clicked();
run = ui.add(egui::Button::new(RichText::new("OK").color(egui::Color32::WHITE)).fill(t.accent).min_size(vec2(64.0, 0.0))).clicked();
run = ui
.add_enabled(
invalid == 0,
egui::Button::new(RichText::new("OK").color(egui::Color32::WHITE)).fill(t.accent).min_size(vec2(64.0, 0.0)),
)
.on_disabled_hover_text("A field holds something that is not a value")
.clicked();
});
});
if ui.input(|i| i.key_pressed(egui::Key::Escape)) && app.ui.menu.is_none() && !app.ui.palette {
+150
View File
@@ -0,0 +1,150 @@
//! Form field widgets: a labelled row, a number field that keeps what is being typed until it
//! is a number, a drop-down, a text field. They know nothing of FieldCraft (colours and widths
//! come in a [`Style`]), so they can move to the shared Craft UI crate.
//!
//! The row layout (a label column of fixed width, clamped for long labels, then the field) is
//! adapted from SolidCraft's dialog rows (`crates/ui-egui/src/dialogs.rs`, MIT OR Apache-2.0).
use egui::{Color32, Id, RichText, Ui, vec2};
/// Widths and colours of a form.
#[derive(Clone, Copy, Debug)]
pub struct Style {
/// The label column.
pub label_width: f32,
/// A field (number, text, drop-down) without its unit.
pub field_width: f32,
pub dim: Color32,
pub error: Color32,
/// The mark of a required field.
pub required: Color32,
}
/// Row height.
pub const ROW_H: f32 = 22.0;
/// A row: `label` (with a mark when `required`) in the label column, then `add`.
pub fn row<R>(ui: &mut Ui, s: &Style, label: &str, required: bool, add: impl FnOnce(&mut Ui) -> R) -> R {
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(s.label_width, ROW_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_width(s.label_width);
ui.spacing_mut().item_spacing.x = 2.0;
ui.add(egui::Label::new(RichText::new(label).color(s.dim)).truncate()).on_hover_text(label);
if required {
ui.label(RichText::new("*").color(s.required)).on_hover_text("Required");
}
});
add(ui)
})
.inner
}
/// A message under a field, aligned with the fields.
pub fn note(ui: &mut Ui, s: &Style, text: &str, color: Color32) {
ui.horizontal(|ui| {
ui.add_space(s.label_width + ui.spacing().item_spacing.x);
ui.add(egui::Label::new(RichText::new(text).small().color(color)).wrap());
});
}
/// What a number field did this frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum NumberEdit {
Unchanged,
/// A new value; `None` when the field was emptied.
Set(Option<f64>),
/// What is typed is not a number (yet); the value is unchanged.
Invalid,
}
/// The typed text of a field and the value it was typed over: kept while the value stays the
/// same, so a half-typed `1e-` survives frames and an outside change (undo, the JSON view)
/// replaces it.
#[derive(Clone, Debug, Default)]
struct Draft {
text: String,
over: Option<f64>,
}
/// A number field: shows `value` (or the typed text), greys `hint` when empty, and reports a new
/// value only when the text is a finite number (or empty). `integer` refuses fractions.
pub fn number(ui: &mut Ui, s: &Style, id: Id, value: Option<f64>, hint: &str, integer: bool, width: f32) -> NumberEdit {
let shown = |v: Option<f64>| v.map(format_number).unwrap_or_default();
let mut draft = ui.data(|d| d.get_temp::<Draft>(id)).filter(|d| d.over == value).unwrap_or_else(|| Draft { text: shown(value), over: value });
let parse = |t: &str| -> Option<Option<f64>> {
let t = t.trim();
if t.is_empty() {
return Some(None);
}
let x = t.parse::<f64>().ok().filter(|x| x.is_finite())?;
(!integer || x.fract() == 0.0).then_some(Some(x))
};
let bad = parse(&draft.text).is_none();
let mut edit = egui::TextEdit::singleline(&mut draft.text).desired_width(width).hint_text(RichText::new(hint).italics().color(s.dim));
if bad {
edit = edit.text_color(s.error);
}
let r = ui.add(edit);
let out = if r.changed() {
match parse(&draft.text) {
Some(v) => {
draft.over = v;
NumberEdit::Set(v)
}
None => NumberEdit::Invalid,
}
} else if bad {
NumberEdit::Invalid
} else {
NumberEdit::Unchanged
};
ui.data_mut(|d| d.insert_temp(id, draft));
out
}
/// A number as typed: integers without a fraction, others in their shortest round-trip form.
pub fn format_number(x: f64) -> String {
if x.fract() == 0.0 && x.abs() < 1e15 {
format!("{}", x as i64)
} else {
serde_json::Number::from_f64(x).map_or_else(|| x.to_string(), |n| n.to_string())
}
}
/// A drop-down of `options` (labels); returns the index picked this frame.
pub fn combo(ui: &mut Ui, id: Id, selected: &str, options: &[&str], width: f32) -> Option<usize> {
let mut picked = None;
egui::ComboBox::from_id_salt(id).selected_text(selected).width(width).truncate().show_ui(ui, |ui| {
for (i, o) in options.iter().enumerate() {
if ui.selectable_label(*o == selected, *o).clicked() {
picked = Some(i);
}
}
});
picked
}
/// A one-line text field with a greyed hint; true when it changed.
pub fn text(ui: &mut Ui, s: &Style, value: &mut String, hint: &str, width: f32) -> bool {
ui.add(egui::TextEdit::singleline(value).desired_width(width).hint_text(RichText::new(hint).italics().color(s.dim))).changed()
}
/// A unit after a field.
pub fn unit(ui: &mut Ui, s: &Style, unit: Option<&str>) {
if let Some(u) = unit {
ui.label(RichText::new(u).small().color(s.dim));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numbers_print_as_typed() {
assert_eq!(format_number(210000.0), "210000");
assert_eq!(format_number(0.3), "0.3");
assert_eq!(format_number(7.85e-9), "7.85e-9");
assert_eq!(format_number(-2.0), "-2");
}
}
+1 -1
View File
@@ -1030,7 +1030,7 @@ mod tests {
assert!(!app.display().bounds.is_empty());
app.start("mesh.tet.generate").unwrap();
assert_eq!(app.dialog.as_ref().map(|d| (d.command, d.text.as_str())), Some(("mesh.tet.generate", "{}")));
assert_eq!(app.dialog.as_ref().map(|d| (d.command, d.text.as_str())), Some(("mesh.tet.generate", r#"{"order":"linear","quality":true}"#)));
app.submit_dialog();
assert_eq!(app.busy(), Some("Generate Tet Mesh"), "meshing runs off the window thread");
app.wait_loaded();
+454
View File
@@ -0,0 +1,454 @@
//! 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);
}
}
}
}
+7 -1
View File
@@ -28,7 +28,9 @@ pub mod browser;
pub mod capture;
pub mod context;
pub mod control;
pub mod fields;
pub mod files;
pub mod form;
pub mod frame;
pub mod gpu;
pub mod icons;
@@ -155,6 +157,8 @@ pub struct ParamsDialog {
/// The parameters as JSON text.
pub text: String,
pub error: Option<String>,
/// The JSON text is shown instead of the typed fields.
pub advanced: bool,
}
/// A status bar message.
@@ -519,7 +523,9 @@ impl ModelApp {
/// Open a command's parameter form with `params` filled in.
pub fn open_form(&mut self, command: &str, params: Value) {
if let Some(spec) = fieldcraft_engine::find_command(command) {
self.dialog = Some(ParamsDialog { command: spec.id, text: params.to_string(), error: None });
// The schema's defaults are filled in, so the form starts as the command would run.
let params = spec.form.map_or(params.clone(), |form| fieldcraft_engine::schema::prefill(form, &params));
self.dialog = Some(ParamsDialog { command: spec.id, text: params.to_string(), error: None, advanced: spec.form.is_none() });
}
}