forked from 1b-agent/fieldcraft-upstream
A checkbox whose key is unset shows the field's default (#353). An empty JSON field shows its format hint (#340). Vector components share a line only when each fits a value such as -12000, else take a line each (#339). A space typed in a definition name becomes _ and the form says why (#320). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
902 lines
42 KiB
Rust
902 lines
42 KiB
Rust
//! 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, BTreeSet};
|
||
|
||
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: as wide as the form's longest label, at least this…
|
||
const LABEL_MIN: f32 = 72.0;
|
||
/// …and at most this share of the form's width; longer labels wrap.
|
||
const LABEL_SHARE: f32 = 0.45;
|
||
/// The narrowest field.
|
||
const FIELD_MIN: f32 = 48.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 open form (its command and the opening), so no widget
|
||
/// state is shared with another form.
|
||
pub salt: Id,
|
||
/// The fields the user has edited: their problems show beside them; the others wait.
|
||
pub touched: &'a BTreeSet<String>,
|
||
/// OK was tried: every problem shows.
|
||
pub attempted: bool,
|
||
/// The command refused a parameter: its path and the problem as the form phrases it, shown
|
||
/// beside the field.
|
||
pub refused: Option<(&'a str, &'a str)>,
|
||
/// What is missing or wrong in the parameters ([`messages`]).
|
||
pub problems: &'a [(String, String)],
|
||
}
|
||
|
||
/// 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,
|
||
/// A reference field's "Create …" was clicked: the collection to add to and the field's path.
|
||
pub create: Option<(Collection, String)>,
|
||
/// The paths of the fields edited this frame.
|
||
pub touched: Vec<String>,
|
||
}
|
||
|
||
struct Form<'a> {
|
||
cx: &'a Context<'a>,
|
||
style: Style,
|
||
/// Each field's problem, by path, phrased with its label ([`messages`]).
|
||
problems: BTreeMap<String, String>,
|
||
names: BTreeMap<&'static str, Vec<String>>,
|
||
out: Outcome,
|
||
}
|
||
|
||
/// The form's look; `label_width` is the label column ([`label_column`]).
|
||
pub fn style(t: &Tokens, label_width: f32) -> Style {
|
||
Style { label_width, dim: t.text_dim, error: t.error, required: t.accent }
|
||
}
|
||
|
||
/// The label column of `fields` in a form `width` wide: its longest label on one line with its
|
||
/// marks, within [`LABEL_MIN`] and [`LABEL_SHARE`] of the width. Labels longer than that wrap; the
|
||
/// column widens when one would need more than [`fields::LABEL_LINES`] lines, as long as a field
|
||
/// keeps [`FIELD_MIN`].
|
||
pub fn label_column(ui: &Ui, fields: &[Field], units: UnitSystem, width: f32) -> f32 {
|
||
let share = (width * LABEL_SHARE).max(LABEL_MIN);
|
||
let most = (width - FIELD_MIN - ui.spacing().item_spacing.x).max(share);
|
||
let mut column = LABEL_MIN;
|
||
each_label(fields, units, 0, &mut |label, required, help| {
|
||
let one_line = fields::label_width(ui, label, required, help);
|
||
let need = if one_line <= share { one_line } else { wrapped_width(ui, label, required, help, share, one_line.min(most)) };
|
||
column = column.max(need);
|
||
});
|
||
column.min(most)
|
||
}
|
||
|
||
/// The narrowest column in `lo..=hi` that shows `label` whole on [`fields::LABEL_LINES`] lines.
|
||
fn wrapped_width(ui: &Ui, label: &str, required: bool, help: bool, mut lo: f32, mut hi: f32) -> f32 {
|
||
let marks = fields::marks_width(required, help);
|
||
let fits = |column: f32| !fields::label_galley(ui, label, egui::Color32::PLACEHOLDER, column - marks).elided;
|
||
if fits(lo) {
|
||
return lo;
|
||
}
|
||
// Bisection to a point.
|
||
for _ in 0..12 {
|
||
if hi - lo < 1.0 {
|
||
break;
|
||
}
|
||
let mid = (lo + hi) / 2.0;
|
||
if fits(mid) { hi = mid } else { lo = mid }
|
||
}
|
||
hi.ceil()
|
||
}
|
||
|
||
/// Narrowest box of a vector component on a shared line: wide enough for `-12000.5`.
|
||
const VECTOR_MIN: f32 = 64.0;
|
||
|
||
/// A definition's name field (`name`, `new_name`), whose text must be a deck-safe name.
|
||
fn is_name(f: &Field) -> bool {
|
||
matches!(f.kind, Kind::Text) && matches!(f.key, "name" | "new_name")
|
||
}
|
||
|
||
/// A field's width: the rest of its row.
|
||
fn fill(ui: &Ui) -> f32 {
|
||
ui.available_width().max(FIELD_MIN)
|
||
}
|
||
|
||
/// Each row label of `fields` (nested too): its text, whether it is required and has help.
|
||
fn each_label(fields: &[Field], units: UnitSystem, depth: usize, out: &mut dyn FnMut(&str, bool, bool)) {
|
||
if depth > MAX_DEPTH {
|
||
return;
|
||
}
|
||
for f in fields {
|
||
let help = !f.help_text(units).is_empty();
|
||
match f.kind {
|
||
Kind::Variants { variants, .. } => {
|
||
// A flattened variant's row is the field's own; a keyed one's is "Type".
|
||
if f.key.is_empty() {
|
||
out(f.label, f.required, help);
|
||
} else {
|
||
out("Type", true, help);
|
||
}
|
||
for v in variants {
|
||
each_label(v.fields, units, depth + 1, out);
|
||
}
|
||
}
|
||
Kind::Group(inner) | Kind::Rows { fields: inner, .. } => each_label(inner, units, depth + 1, out),
|
||
_ => out(f.label, f.required, help),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Draw `fields` editing `params` (an object).
|
||
pub fn show(ui: &mut Ui, t: &Tokens, cx: &Context, fields: &'static [Field], params: &mut Map<String, Value>) -> Outcome {
|
||
let problems = cx.problems.iter().cloned().collect();
|
||
let label_width = label_column(ui, fields, cx.units, ui.available_width());
|
||
let mut f = Form { cx, style: style(t, label_width), problems, names: BTreeMap::new(), out: Outcome::default() };
|
||
ui.spacing_mut().item_spacing.y = 3.0;
|
||
if cx.units == UnitSystem::Unspecified && has_system_units(fields, 0) {
|
||
let note = format!("No unit system: numbers are in the model's own consistent units. {}", schema::UNITS_HINT);
|
||
ui.add(egui::Label::new(RichText::new(note).small().color(f.style.dim)).wrap());
|
||
}
|
||
f.fields(ui, fields, fields, params, "", 0);
|
||
f.out
|
||
}
|
||
|
||
impl Form<'_> {
|
||
/// The problem with the field at `path`, under it: the command's refusal at once, the
|
||
/// form's own once the field was edited or OK was tried.
|
||
fn problem_note(&self, ui: &mut Ui, path: &str) {
|
||
let message = match self.cx.refused {
|
||
Some((key, message)) if key == path => Some(message.to_string()),
|
||
_ => self.problems.get(path).filter(|_| self.cx.attempted || self.cx.touched.contains(path)).cloned(),
|
||
};
|
||
if let Some(message) = message {
|
||
fields::note(ui, &self.style, &message, self.style.error);
|
||
}
|
||
}
|
||
|
||
fn touch(&mut self, path: &str) {
|
||
self.out.changed = true;
|
||
self.out.touched.push(path.to_string());
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
/// 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);
|
||
let path = join(prefix, tag);
|
||
if let Some(t) = self.variant_choice(ui, f, variants, current.as_deref(), &path) {
|
||
*obj = schema::switch_variant(f, common, obj, &t);
|
||
self.touch(&path);
|
||
}
|
||
self.problem_note(ui, &path);
|
||
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 help = f.help_text(self.cx.units);
|
||
let picked = fields::row(ui, &self.style, f.label, f.required, &help, |ui| fields::combo(ui, id, selected, &labels, fill(ui)));
|
||
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 help = f.help_text(self.cx.units);
|
||
let new = fields::row(ui, &style, f.label, f.required, &help, |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.touch(&path);
|
||
}
|
||
if is_name(f) && ui.data(|d| d.get_temp::<bool>(self.id(&path).with("spaced"))).unwrap_or(false) {
|
||
fields::note(ui, &self.style, "Spaces become _ (names go into solver decks).", self.style.dim);
|
||
}
|
||
if let Some(note) = f.note(obj.get(f.key).and_then(Value::as_str)) {
|
||
fields::note(ui, &self.style, note, self.style.dim);
|
||
}
|
||
if let Kind::Either(options) = f.kind {
|
||
self.either_value(ui, f, options, obj, &path);
|
||
}
|
||
}
|
||
}
|
||
self.problem_note(ui, &path);
|
||
}
|
||
|
||
/// 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 { .. } => {
|
||
let unit = f.unit_label(self.cx.units);
|
||
let width = (fill(ui) - fields::unit_width(ui, unit.as_deref())).max(FIELD_MIN);
|
||
let out = self.number(ui, id, value, f.hint, false, width);
|
||
fields::unit(ui, &s, unit.as_deref());
|
||
out
|
||
}
|
||
Kind::Integer { .. } => self.number(ui, id, value, f.hint, true, fill(ui)),
|
||
Kind::Text | Kind::Path { .. } => {
|
||
let mut text = value.and_then(Value::as_str).unwrap_or_default().to_string();
|
||
let changed = fields::text(ui, &s, id, &mut text, f.hint, fill(ui));
|
||
// Definition names go into solver decks, which take no spaces: a typed space
|
||
// becomes `_`, and the field says so.
|
||
if changed && is_name(f) && text.contains(' ') {
|
||
text = text.replace(' ', "_");
|
||
ui.data_mut(|d| d.insert_temp(id.with("spaced"), true));
|
||
}
|
||
changed.then(|| if text.is_empty() && !f.required { Value::Null } else { json!(text) })
|
||
}
|
||
Kind::Bool => {
|
||
// An unset key runs with the field's default, so the box shows that.
|
||
let mut on = value.and_then(Value::as_bool).unwrap_or(f.default == Some("true"));
|
||
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, fill(ui)).map(|i| options.get(i).map_or(Value::Null, |(v, _)| json!(v)))
|
||
}
|
||
Kind::Vector { labels, nullable, .. } => self.vector(ui, id, value, f, labels, nullable),
|
||
Kind::Ref(c) => self.reference(ui, id, path, value, c, f.hint, f.required),
|
||
Kind::RefList(c) => self.reference_list(ui, path, value, c),
|
||
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, fill(ui)).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, f.hint),
|
||
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 help = option.help_text(self.cx.units);
|
||
let new = fields::row(ui, &style, option.label, false, &help, |ui| self.scalar(ui, option, Some(&value), &sub));
|
||
if let Some(new) = new {
|
||
obj.insert(f.key.into(), json!({key: new}));
|
||
self.touch(path);
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// A vector: one number per component, each after its caption (X, Y, Z or the field's own).
|
||
fn vector(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, f: &Field, 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;
|
||
}
|
||
// Empty slots show their placeholder (x, y, z), not a 0 nobody typed.
|
||
_ => vec![Value::Null; labels.len()],
|
||
};
|
||
let mut next = current.clone();
|
||
let mut changed = false;
|
||
let unit = f.unit_label(self.cx.units);
|
||
// Up to three components share a line when each still shows a value such as -12000
|
||
// whole; otherwise each takes a line of its own.
|
||
let gap = 3.0;
|
||
let small = egui::TextStyle::Small.resolve(ui.style());
|
||
let caption_w = labels.iter().map(|l| crate::elide::width(ui, &caption(l), &small)).fold(0.0, f32::max) + 2.0 * gap;
|
||
let room = fill(ui) - fields::unit_width(ui, unit.as_deref());
|
||
let three = labels.len().clamp(1, 3);
|
||
let per_line = if (room / three as f32 - caption_w) >= VECTOR_MIN { three } else { 1 };
|
||
let width = (room / per_line as f32 - caption_w - 1.0).max(34.0);
|
||
ui.vertical(|ui| {
|
||
for chunk in (0..labels.len()).collect::<Vec<_>>().chunks(per_line) {
|
||
ui.horizontal(|ui| {
|
||
ui.spacing_mut().item_spacing.x = gap;
|
||
for &i in chunk {
|
||
let hint = labels.get(i).copied().unwrap_or("");
|
||
fields::caption(ui, &self.style, &caption(hint));
|
||
let slot = current.get(i).cloned().unwrap_or(Value::Null);
|
||
let shown = (!slot.is_null()).then_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 = v;
|
||
}
|
||
changed = true;
|
||
}
|
||
}
|
||
if chunk.first() == Some(&0) || per_line == 1 {
|
||
fields::unit(ui, &self.style, unit.as_deref());
|
||
}
|
||
});
|
||
}
|
||
});
|
||
// A vector left empty is not written until edited (a required one shows as missing).
|
||
// The slots left empty of a vector that takes no blanks are 0.
|
||
changed.then(|| Value::Array(next.into_iter().map(|x| if x.is_null() && !nullable { json!(0.0) } else { x }).collect()))
|
||
}
|
||
|
||
/// A drop-down of the collection's items; never free text. With none, the drop-down is
|
||
/// greyed and a button opens the command that makes one ([`Outcome::create`]).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn reference(&mut self, ui: &mut Ui, id: Id, path: &str, value: Option<&Value>, c: Collection, hint: &str, required: bool) -> Option<Value> {
|
||
let current = value.and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string);
|
||
let names = self.names(c).to_vec();
|
||
if names.is_empty() {
|
||
ui.vertical(|ui| {
|
||
let none = format!("No {} yet", plural(c.label()));
|
||
ui.add_enabled_ui(false, |ui| fields::combo(ui, id, current.as_deref().unwrap_or(&none), &[], fill(ui)));
|
||
self.create_button(ui, c, path);
|
||
});
|
||
return None;
|
||
}
|
||
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, fill(ui)).map(|i| names.get(i).map_or(Value::Null, |n| json!(n)))
|
||
}
|
||
|
||
fn reference_list(&mut self, ui: &mut Ui, path: &str, value: Option<&Value>, c: Collection) -> 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() {
|
||
ui.label(RichText::new(format!("No {} yet", plural(c.label()))).color(self.style.dim));
|
||
self.create_button(ui, c, path);
|
||
}
|
||
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
|
||
}
|
||
|
||
/// "Create material…": opens the command that makes an item of `c`, then returns here.
|
||
fn create_button(&mut self, ui: &mut Ui, c: Collection, path: &str) {
|
||
let r = c.requirement();
|
||
let text = match r.provider().ends_with(".create") {
|
||
true => format!("Create {}…", c.label()),
|
||
false => format!("{}…", fieldcraft_engine::catalogue::entry_of(r.provider()).map_or(r.provider(), |e| e.label)),
|
||
};
|
||
if ui.button(text).on_hover_text(format!("{} ({})", r.place(), r.provider())).clicked() {
|
||
self.out.create = Some((c, path.to_string()));
|
||
}
|
||
}
|
||
|
||
/// A JSON value typed as text: kept while it does not parse.
|
||
/// Empty, it shows `hint` (the expected format) greyed.
|
||
fn json(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, hint: &str) -> 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)
|
||
.id(id)
|
||
.code_editor()
|
||
.desired_width(fill(ui))
|
||
.hint_text(RichText::new(hint).italics().color(self.style.dim));
|
||
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);
|
||
let help = f.help_text(self.cx.units);
|
||
let heading = if f.required { ui.label(RichText::new(f.label).strong()) } else { ui.checkbox(&mut on, RichText::new(f.label).strong()) };
|
||
if !help.is_empty() {
|
||
heading.on_hover_text(help);
|
||
}
|
||
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.touch(path);
|
||
}
|
||
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 };
|
||
let tag_path = join(path, tag);
|
||
if let Some(t) = self.variant_choice(ui, &row, variants, current.as_deref(), &tag_path) {
|
||
*child = schema::switch_variant(f, &[], child, &t);
|
||
self.touch(&tag_path);
|
||
}
|
||
self.problem_note(ui, &tag_path);
|
||
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.touch(path);
|
||
}
|
||
if list.len() < max && ui.small_button("+ Add").clicked() {
|
||
list.push(schema::prefill(fields, &json!({})));
|
||
self.touch(path);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A vector component's caption: axis names upper-cased ("x" → "X"), others as they are ("E1").
|
||
fn caption(component: &str) -> String {
|
||
if component.len() <= 3 && component.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) {
|
||
component.to_uppercase()
|
||
} else {
|
||
component.to_string()
|
||
}
|
||
}
|
||
|
||
/// Whether any of `fields` (nested too) is a number whose unit follows the unit system.
|
||
pub fn has_system_units(fields: &[Field], depth: usize) -> bool {
|
||
depth <= MAX_DEPTH
|
||
&& fields.iter().any(|f| match f.kind {
|
||
Kind::Number { dim, .. } | Kind::Vector { dim, .. } => dim.follows_system(),
|
||
Kind::Group(inner) | Kind::Rows { fields: inner, .. } | Kind::Either(inner) => has_system_units(inner, depth + 1),
|
||
Kind::Variants { variants, .. } => variants.iter().any(|v| has_system_units(v.fields, depth + 1)),
|
||
_ => false,
|
||
})
|
||
}
|
||
|
||
/// What is missing or wrong in `params`, in the form's order: (field path, message), each
|
||
/// phrased with the field's label as the form shows it ([`schema::Problem::for_form`]).
|
||
pub fn messages(fields: &'static [Field], params: &Map<String, Value>) -> Vec<(String, String)> {
|
||
schema::object_problems(fields, params)
|
||
.into_iter()
|
||
.map(|p| {
|
||
let m = p.for_form();
|
||
(p.path, m)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// A collection's label in the plural: "materials", "point masses", "bodies".
|
||
fn plural(label: &str) -> String {
|
||
if let Some(stem) = label.strip_suffix('y') {
|
||
format!("{stem}ies")
|
||
} else if label.ends_with('s') {
|
||
format!("{label}es")
|
||
} else {
|
||
format!("{label}s")
|
||
}
|
||
}
|
||
|
||
/// Fill each empty required reference that has one obvious candidate, `pick(collection)` (the
|
||
/// browser's selection, or a collection's only item). The form starts with it, so the fields and
|
||
/// the JSON view agree. Returns whether anything was filled.
|
||
pub fn preselect(fields: &[Field], obj: &mut Map<String, Value>, pick: &dyn Fn(Collection) -> Option<String>) -> bool {
|
||
preselect_in(fields, obj, pick, 0)
|
||
}
|
||
|
||
fn preselect_in(fields: &[Field], obj: &mut Map<String, Value>, pick: &dyn Fn(Collection) -> Option<String>, depth: usize) -> bool {
|
||
if depth > MAX_DEPTH {
|
||
return false;
|
||
}
|
||
let mut changed = false;
|
||
for f in fields {
|
||
match f.kind {
|
||
Kind::Variants { tag, .. } if f.key.is_empty() => {
|
||
if let Some(v) = obj.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
|
||
changed |= preselect_in(v.fields, obj, pick, depth + 1);
|
||
}
|
||
}
|
||
Kind::Group(inner) => {
|
||
if let Some(Value::Object(child)) = obj.get_mut(f.key) {
|
||
changed |= preselect_in(inner, child, pick, depth + 1);
|
||
}
|
||
}
|
||
Kind::Variants { tag, .. } => {
|
||
if let Some(Value::Object(child)) = obj.get_mut(f.key)
|
||
&& let Some(v) = child.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t))
|
||
{
|
||
changed |= preselect_in(v.fields, child, pick, depth + 1);
|
||
}
|
||
}
|
||
Kind::Ref(c) if f.required && obj.get(f.key).is_none_or(Value::is_null) => {
|
||
if let Some(name) = pick(c) {
|
||
obj.insert(f.key.into(), json!(name));
|
||
changed = true;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
changed
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use egui::Color32;
|
||
|
||
use super::*;
|
||
|
||
/// Every command's form draws, with its defaults and with hostile parameters, with and without
|
||
/// items to reference, without panicking, and drawing alone changes nothing.
|
||
#[test]
|
||
fn every_form_draws() {
|
||
let ctx = egui::Context::default();
|
||
let one = |c: Collection| vec![format!("{}1", c.label())];
|
||
let none = |_: Collection| Vec::new();
|
||
let hostile =
|
||
[json!({}), json!({"type": 7, "name": [], "elastic": "x", "target": {"set": 1, "reference_point": 2}, "values": [1], "geometry": {}})];
|
||
for (c, names) in fieldcraft_engine::commands().iter().flat_map(|c| [(c, &one as &dyn Fn(Collection) -> Vec<String>), (c, &none)]) {
|
||
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 touched = BTreeSet::new();
|
||
let cx = Context {
|
||
units: UnitSystem::MmTonneS,
|
||
names,
|
||
salt: Id::new(c.id),
|
||
touched: &touched,
|
||
attempted: true,
|
||
refused: None,
|
||
problems: &messages(form, ¶ms),
|
||
};
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Every form's labels show whole, wrapped at most onto two lines, with their marks inside
|
||
/// the label column, at the context panel's width and at a narrow one, in a 1280×800 window.
|
||
#[test]
|
||
fn no_label_or_mark_is_clipped() {
|
||
let ctx = egui::Context::default();
|
||
let input =
|
||
|| egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), ..Default::default() };
|
||
let one = |c: Collection| vec![format!("{} with a long name", c.label())];
|
||
let mut checked = 0;
|
||
for width in [crate::context::WIDTH - 16.0, 200.0] {
|
||
for c in fieldcraft_engine::commands() {
|
||
let Some(form) = c.form else { continue };
|
||
let mut params = schema::prefill(form, &json!({})).as_object().cloned().unwrap_or_default();
|
||
let mut labels = BTreeSet::new();
|
||
each_label(form, UnitSystem::MmTonneS, 0, &mut |l, _, _| {
|
||
labels.insert(l.to_string());
|
||
});
|
||
let (mut left, mut column, mut cut) = (0.0, 0.0, Vec::new());
|
||
let mut out = ctx.run_ui(input(), |ui| {
|
||
let touched = BTreeSet::new();
|
||
let cx = Context {
|
||
units: UnitSystem::MmTonneS,
|
||
names: &one,
|
||
salt: Id::new(c.id),
|
||
touched: &touched,
|
||
attempted: false,
|
||
refused: None,
|
||
problems: &[],
|
||
};
|
||
ui.vertical(|ui| {
|
||
ui.set_max_width(width);
|
||
left = ui.min_rect().left();
|
||
column = label_column(ui, form, cx.units, width);
|
||
// Every label (also of sections not open), laid out as its row lays it out.
|
||
each_label(form, cx.units, 0, &mut |label, required, help| {
|
||
let wrap = column - fields::marks_width(required, help);
|
||
if fields::label_galley(ui, label, Color32::WHITE, wrap).elided {
|
||
cut.push(label.to_string());
|
||
}
|
||
});
|
||
show(ui, &Tokens::get(false), &cx, form, &mut params);
|
||
});
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(cut.is_empty(), "{} at {width}: {cut:?} do not fit {column}", c.id);
|
||
assert!(column <= width - FIELD_MIN, "{}: {column}", c.id);
|
||
// A row's column starts at its label (nested rows are indented); its marks follow.
|
||
let mut row_left = left;
|
||
for shape in &out.shapes {
|
||
let egui::Shape::Text(t) = &shape.shape else { continue };
|
||
let text = t.galley.text();
|
||
let rect = t.galley.rect.translate(t.pos.to_vec2());
|
||
let end = row_left + column + 0.5;
|
||
if labels.contains(text) && rect.left() < left + width / 2.0 {
|
||
row_left = rect.left();
|
||
assert!(!t.galley.elided, "{} at {width}: `{text}` elided", c.id);
|
||
assert!(rect.right() <= rect.left() + column + 0.5, "{} at {width}: `{text}` past the column", c.id);
|
||
checked += 1;
|
||
} else if (text == "*" || text == "i") && rect.left() < end {
|
||
assert!(rect.right() <= end, "{} at {width}: a mark past the column: {rect:?}", c.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert!(checked > 100, "{checked}");
|
||
}
|
||
|
||
/// Long labels share the column: the Plasticity form's "Hardening modulus" shows whole.
|
||
#[test]
|
||
fn hardening_modulus_shows_whole() {
|
||
let text = drawn("model.material.plasticity", UnitSystem::MmTonneS, json!({"plastic": {"type": "bilinear"}}));
|
||
assert!(text.lines().any(|l| l == "Hardening modulus"), "{text}");
|
||
}
|
||
|
||
/// In every form, each required field left empty is a problem, so OK waits, and the message
|
||
/// names the field by its label, never by a JSON key.
|
||
#[test]
|
||
fn every_required_field_left_empty_is_named_by_its_label() {
|
||
let mut checked = 0;
|
||
for c in fieldcraft_engine::commands() {
|
||
let Some(form) = c.form else { continue };
|
||
let full = schema::prefill(form, &json!({}));
|
||
let Some(full) = full.as_object() else { continue };
|
||
for (path, message) in messages(form, full) {
|
||
assert!(!message.contains('`'), "{}: {path}: {message}", c.id);
|
||
}
|
||
for f in form.iter().filter(|f| f.required) {
|
||
let key = match f.kind {
|
||
Kind::Variants { tag, .. } if f.key.is_empty() => tag,
|
||
_ => f.key,
|
||
};
|
||
let mut params = full.clone();
|
||
params.remove(key);
|
||
let found: BTreeMap<String, String> = messages(form, ¶ms).into_iter().collect();
|
||
let message = found.get(key).unwrap_or_else(|| panic!("{}: `{key}` empty is no problem: {found:?}", c.id));
|
||
assert!(message.starts_with(&format!("{}: ", f.label)), "{}: {message}", c.id);
|
||
for m in found.values() {
|
||
assert!(!m.contains('`'), "{}: {m}", c.id);
|
||
}
|
||
checked += 1;
|
||
}
|
||
}
|
||
assert!(checked > 50, "{checked}");
|
||
}
|
||
|
||
#[test]
|
||
fn messages_read_as_the_form_shows_them() {
|
||
let plasticity = fieldcraft_engine::find_command("model.material.plasticity").and_then(|c| c.form).unwrap();
|
||
let m = messages(plasticity, &Map::new());
|
||
assert_eq!(m.first(), Some(&("name".to_string(), "Material: choose a material".to_string())), "{m:?}");
|
||
let load = fieldcraft_engine::find_command("model.load.create").and_then(|c| c.form).unwrap();
|
||
let m: Vec<String> = messages(load, &json!({"type": "force"}).as_object().cloned().unwrap_or_default()).into_iter().map(|(_, m)| m).collect();
|
||
assert_eq!(m, ["Name: enter a name", "Target: choose a set or a reference point", "Force: required"]);
|
||
}
|
||
|
||
/// A problem waits until its field is edited or OK is tried; a refused parameter shows at once.
|
||
#[test]
|
||
fn problems_show_once_touched_or_attempted() {
|
||
let form = fieldcraft_engine::find_command("model.material.plasticity").and_then(|c| c.form).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let draw = |touched: &BTreeSet<String>, attempted: bool, refused: Option<(&str, &str)>| {
|
||
let mut shown = String::new();
|
||
let mut params = Map::new();
|
||
let names = |_: Collection| Vec::new();
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
let cx = Context {
|
||
units: UnitSystem::MmTonneS,
|
||
names: &names,
|
||
salt: Id::new("t"),
|
||
touched,
|
||
attempted,
|
||
refused,
|
||
problems: &messages(form, ¶ms),
|
||
};
|
||
show(ui, &Tokens::get(false), &cx, form, &mut params);
|
||
});
|
||
out.textures_delta.clear();
|
||
// The text drawn this frame.
|
||
for clipped in &out.shapes {
|
||
if let egui::Shape::Text(t) = &clipped.shape {
|
||
shown.push_str(t.galley.text());
|
||
shown.push('\n');
|
||
}
|
||
}
|
||
shown
|
||
};
|
||
let none = BTreeSet::new();
|
||
assert!(!draw(&none, false, None).contains("choose a material"));
|
||
assert!(draw(&none, true, None).contains("Material: choose a material"));
|
||
let touched: BTreeSet<String> = ["name".to_string()].into();
|
||
assert!(draw(&touched, false, None).contains("Material: choose a material"));
|
||
let refused = draw(&none, false, Some(("name", "Material: names no material")));
|
||
assert!(refused.contains("Material: names no material") && !refused.contains("choose a material"), "{refused}");
|
||
}
|
||
|
||
/// The text a form draws, with `units`.
|
||
fn drawn(id: &str, units: UnitSystem, params: Value) -> String {
|
||
let form = fieldcraft_engine::find_command(id).and_then(|c| c.form).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let mut params = params.as_object().cloned().unwrap_or_default();
|
||
let names = |_: Collection| Vec::new();
|
||
let touched = BTreeSet::new();
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
let cx = Context { units, names: &names, salt: Id::new("t"), touched: &touched, attempted: false, refused: None, problems: &[] };
|
||
show(ui, &Tokens::get(false), &cx, form, &mut params);
|
||
});
|
||
out.textures_delta.clear();
|
||
out.shapes
|
||
.iter()
|
||
.filter_map(|c| if let egui::Shape::Text(t) = &c.shape { Some(t.galley.text().to_string()) } else { None })
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
/// Without a unit system a number shows its dimension and the form says where to set the
|
||
/// system; with one, the unit. A vector's components are captioned.
|
||
#[test]
|
||
fn units_dimensions_and_vector_captions_show() {
|
||
let bare = drawn("model.material.plasticity", UnitSystem::Unspecified, json!({"plastic": {"type": "bilinear"}}));
|
||
assert!(bare.contains("[stress]") && bare.contains(schema::UNITS_HINT), "{bare}");
|
||
let mpa = drawn("model.material.plasticity", UnitSystem::MmTonneS, json!({"plastic": {"type": "bilinear"}}));
|
||
assert!(mpa.contains("MPa") && !mpa.contains(schema::UNITS_HINT), "{mpa}");
|
||
let load = drawn("model.load.create", UnitSystem::Unspecified, json!({"type": "force"}));
|
||
let lines: Vec<&str> = load.lines().collect();
|
||
assert!(["X", "Y", "Z", "[force]"].iter().all(|w| lines.contains(w)), "{load}");
|
||
}
|
||
}
|