forked from 1b-agent/fieldcraft-upstream
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
455 lines
21 KiB
Rust
455 lines
21 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;
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|