//! 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, /// 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, names: BTreeMap<&'static str, Vec>, 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) -> 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 { 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, 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 { 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, 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 { 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, 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 { 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 { let current: Vec = 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::>().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 { 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 { let chosen: Vec = 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 = 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 { 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::(&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::(&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, 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, 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); } } } }