diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4d69875..be4bf66 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index 2044aba..813186f 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -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(¶ms).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 { diff --git a/crates/ui-egui/src/fields.rs b/crates/ui-egui/src/fields.rs new file mode 100644 index 0000000..ff889a8 --- /dev/null +++ b/crates/ui-egui/src/fields.rs @@ -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(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), + /// 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, +} + +/// 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, hint: &str, integer: bool, width: f32) -> NumberEdit { + let shown = |v: Option| v.map(format_number).unwrap_or_default(); + let mut draft = ui.data(|d| d.get_temp::(id)).filter(|d| d.over == value).unwrap_or_else(|| Draft { text: shown(value), over: value }); + let parse = |t: &str| -> Option> { + let t = t.trim(); + if t.is_empty() { + return Some(None); + } + let x = t.parse::().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 { + 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"); + } +} diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index b394a34..bb9b6eb 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -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(); diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs new file mode 100644 index 0000000..11d2afe --- /dev/null +++ b/crates/ui-egui/src/form.rs @@ -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, + /// 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); + } + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c506f4a..6a35b5b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, + /// 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, ¶ms)); + self.dialog = Some(ParamsDialog { command: spec.id, text: params.to_string(), error: None, advanced: spec.form.is_none() }); } }