diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 1383a5e..05881d0 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -9,6 +9,7 @@ //! ATTRIBUTION.md. use egui::{Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2, pos2, vec2}; +use fieldcraft_engine::schema::Collection; use fieldcraft_model::Model; use serde::Serialize; use serde_json::{Value, json}; @@ -39,6 +40,22 @@ pub enum Def { } impl Def { + /// The reference fields that name one of these. + pub fn collection(self) -> Collection { + match self { + Def::Set => Collection::Set, + Def::Material => Collection::Material, + Def::Section => Collection::Section, + Def::Step => Collection::Step, + Def::BoundaryCondition => Collection::BoundaryCondition, + Def::Load => Collection::Load, + Def::ReferencePoint => Collection::ReferencePoint, + Def::Coupling => Collection::Coupling, + Def::Contact => Collection::Contact, + Def::FluidDomain => Collection::FluidDomain, + } + } + /// The commands' stem: `model..create|edit|delete`. pub fn stem(self) -> &'static str { match self { diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index da68a72..7e9c243 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -118,6 +118,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { } }); let mut invalid = 0; + let mut create = None; // 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(); @@ -135,6 +136,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { let cx = crate::form::Context { units, names: &|c| session.names(c), salt: form_id }; let out = crate::form::show(ui, t, &cx, fields, &mut obj); invalid = out.invalid; + create = out.create; if out.changed { d.text = Value::Object(obj).to_string(); d.error = None; @@ -167,7 +169,9 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { cancel = true; } if cancel { - app.dialog = None; + app.close_dialog(); + } else if let Some((collection, path)) = create { + app.create_from_form(collection, path); } else if run { app.submit_dialog(); } else if use_selection diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ef7a6a6..be14c18 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -278,7 +278,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> return err("no command form is open (ui.start opens one)"); } if b("cancel") { - app.dialog = None; + app.close_dialog(); return ok(Value::Null); } if let (Some(d), Some(params)) = (app.dialog.as_mut(), p.get("params")) { diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs index 566f4d3..f7118f6 100644 --- a/crates/ui-egui/src/form.rs +++ b/crates/ui-egui/src/form.rs @@ -35,6 +35,8 @@ 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)>, } struct Form<'a> { @@ -173,8 +175,8 @@ impl Form<'_> { 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::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()), @@ -268,14 +270,19 @@ impl Form<'_> { 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); + /// 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 { + 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() { - let mut text = current.unwrap_or_default(); - let hint = format!("no {} yet", c.label()); - return fields::text(ui, &self.style, id, &mut text, &hint, FIELD_W + 40.0) - .then(|| if text.is_empty() { Value::Null } else { json!(text) }); + 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), &[], FIELD_W + 40.0)); + 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()); @@ -286,15 +293,15 @@ impl Form<'_> { 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 { + fn reference_list(&mut self, ui: &mut Ui, path: &str, value: Option<&Value>, c: Collection) -> 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)); + 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); @@ -308,6 +315,18 @@ impl Form<'_> { 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. fn json(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>) -> Option { let shown = value.map(Value::to_string).unwrap_or_default(); @@ -417,19 +436,74 @@ impl Form<'_> { } } +/// 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, pick: &dyn Fn(Collection) -> Option) -> bool { + preselect_in(fields, obj, pick, 0) +} + +fn preselect_in(fields: &[Field], obj: &mut Map, pick: &dyn Fn(Collection) -> Option, 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 super::*; - /// Every command's form draws, with its defaults and with hostile parameters, without - /// panicking, and drawing alone changes nothing. + /// 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 names = |c: Collection| vec![format!("{}1", c.label())]; + 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 in fieldcraft_engine::commands() { + for (c, names) in fieldcraft_engine::commands().iter().flat_map(|c| [(c, &one as &dyn Fn(Collection) -> Vec), (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(); @@ -438,7 +512,7 @@ mod tests { 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: Id::new(c.id) }; + let cx = Context { units: UnitSystem::MmTonneS, names, salt: Id::new(c.id) }; ui.vertical(|ui| { ui.set_max_width(width); outcome = show(ui, &Tokens::get(false), &cx, form, &mut params); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f06420c..4893d9d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -163,6 +163,19 @@ pub struct ParamsDialog { /// Which opening of a form this is (counts up): a form opened over another one, even of the /// same command, gets fresh widget ids, so nothing typed into the one it replaces carries over. pub opening: u64, + /// Opened from another form's "Create …" button: the form to go back to. + pub back: Option>, +} + +/// The form a "Create …" button left, and where the item made goes in it. +#[derive(Clone, Debug, PartialEq)] +pub struct Back { + pub dialog: ParamsDialog, + pub collection: fieldcraft_engine::schema::Collection, + /// The reference field's path in its parameters (`a/b`). + pub path: String, + /// The collection's names before: the one added is the new item. + pub before: Vec, } /// A status bar message. @@ -550,7 +563,17 @@ impl ModelApp { pub fn open_form(&mut self, command: &str, params: Value) { if let Some(spec) = fieldcraft_engine::find_command(command) { // 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)); + let mut params = spec.form.map_or(params.clone(), |form| fieldcraft_engine::schema::prefill(form, ¶ms)); + // A required reference with one obvious candidate starts picked. + if let (Some(form), Some(obj)) = (spec.form, params.as_object_mut()) { + let selected = self.selected.clone(); + let session = &self.session; + let pick = |c| match &selected { + Some((kind, name)) if kind.collection() == c => Some(name.clone()), + _ => Some(session.names(c)).filter(|n| n.len() == 1).and_then(|n| n.into_iter().next()), + }; + form::preselect(form, obj, &pick); + } self.forms_opened = self.forms_opened.wrapping_add(1); self.dialog = Some(ParamsDialog { command: spec.id, @@ -558,16 +581,54 @@ impl ModelApp { error: None, advanced: spec.form.is_none(), opening: self.forms_opened, + back: None, }); } } + /// A reference field's "Create …": open the command that makes an item of `collection` over + /// the open form, which comes back (with the new item at `path`) when that command runs or + /// is cancelled. A provider that opens no form (a file picker, a missing requirement) leaves + /// the form as it was. + pub fn create_from_form(&mut self, collection: fieldcraft_engine::schema::Collection, path: String) { + let Some(parent) = self.dialog.take() else { return }; + let before = self.session.names(collection); + let provider = collection.requirement().provider(); + let _ = self.start(provider); + match self.dialog.as_mut() { + Some(d) if d.command == provider && d.back.is_none() => d.back = Some(Box::new(Back { dialog: parent, collection, path, before })), + _ => self.dialog = Some(parent), + } + } + + /// Close the open form: back to the form it was opened from, if any. + pub fn close_dialog(&mut self) { + self.dialog = self.dialog.take().and_then(|d| d.back).map(|b| b.dialog); + } + + /// The open form's command ran: back to the form it was opened from, with the item it made + /// in the reference field. + fn dialog_done(&mut self) { + let Some(back) = self.dialog.take().and_then(|d| d.back) else { return }; + let Back { mut dialog, collection, path, before } = *back; + let made = self.session.names(collection).into_iter().find(|n| !before.contains(n)); + if let (Some(name), Ok(mut params)) = (made, serde_json::from_str::(&dialog.text)) { + let (parent, key) = path.rsplit_once('/').unwrap_or(("", &path)); + let pointer = if parent.is_empty() { String::new() } else { format!("/{parent}") }; + if let Some(Value::Object(obj)) = params.pointer_mut(&pointer) { + obj.insert(key.into(), json!(name)); + } + dialog.text = params.to_string(); + } + self.dialog = Some(dialog); + } + /// Run the open parameter form; it closes when the command succeeds. pub fn run_dialog(&mut self) -> Result { let (command, params) = self.dialog_params()?; let r = self.run(command, params); match &r { - Ok(_) => self.dialog = None, + Ok(_) => self.dialog_done(), Err(e) => { if let Some(open) = self.dialog.as_mut() { open.error = Some(e.clone()); @@ -1370,6 +1431,53 @@ mod tests { assert!(app.start("no.such").is_err()); } + /// A required reference with one candidate starts picked (the browser's selection wins); with + /// none, "Create …" opens the providing command over the form, which comes back with the new + /// item picked (or as it was, when that command is cancelled). + #[test] + fn reference_fields_pick_the_only_candidate_and_create_a_missing_one() { + use fieldcraft_engine::schema::Collection; + let params = |app: &ModelApp| serde_json::from_str::(&app.dialog.as_ref().unwrap().text).unwrap(); + let steel = json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}); + let mut app = ModelApp::new(Session::default()); + app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap(); + app.run("model.set.create", json!({"name": "all", "nodes": [1]})).unwrap(); + // (Opened directly: from the toolbar, Section waits for a material.) + app.open_form("model.section.create", json!({})); + let opened = params(&app); + assert_eq!(opened["set"], "all"); + assert!(opened.get("material").is_none()); + + // Cancelled: back to the section form as it was. + app.create_from_form(Collection::Material, "material".into()); + assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.material.create")); + app.close_dialog(); + assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.section.create")); + assert_eq!(params(&app), opened); + + // Made: back with it picked. + app.create_from_form(Collection::Material, "material".into()); + app.dialog.as_mut().unwrap().text = steel.to_string(); + app.run_dialog().unwrap(); + let d = app.dialog.as_ref().unwrap(); + assert_eq!(d.command, "model.section.create"); + assert!(d.back.is_none()); + assert_eq!(params(&app)["material"], "Steel"); + app.close_dialog(); + assert!(app.dialog.is_none()); + + // One material: Plasticity starts with it. + app.start("model.material.plasticity").unwrap(); + assert_eq!(params(&app)["name"], "Steel"); + // Two: the one selected in the browser, else nothing. + app.run("model.material.create", json!({"name": "Al", "elastic": {"type": "isotropic", "young": 70000, "poisson": 0.33}})).unwrap(); + app.start("model.material.plasticity").unwrap(); + assert!(params(&app).get("name").is_none()); + app.selected = Some((browser::Def::Material, "Steel".into())); + app.start("model.material.plasticity").unwrap(); + assert_eq!(params(&app)["name"], "Steel"); + } + #[test] fn toolbar_entries_start_their_command_and_planned_ones_do_nothing() { let mut app = ModelApp::new(Session::default()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 980b727..7926731 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -51,7 +51,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | `ui.select` (viewport) | any of `filter`, `box_mode`, `mode`, `clear`, `at`, `box`, `geometry`, `nodes`, `elements`, `faces` (and no `kind`) | changes the viewport's selection (see [Selection](#selection)); returns `ui.selection`'s reply | | `ui.selection` | — | the viewport's selection: `filter`, `box_mode` (box selection on), `count`, `capped`, `summary`, `items`, `params` (the selection as a set's members), `hover` (what a click would pick under the pointer) | | `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close` | answers the window's question (`ui.inspect`'s `prompt`) | -| `ui.dialog` | `params?` (JSON object, fills the form), `use_selection?`, `run?`, `cancel?` | drives the open command form, in that order; `use_selection` is the form's Target row (see [Selection](#selection)); `run` returns the command's result and closes the form on success | +| `ui.dialog` | `params?` (JSON object, fills the form), `use_selection?`, `run?`, `cancel?` | drives the open command form, in that order; `use_selection` is the form's Target row (see [Selection](#selection)); `run` returns the command's result and closes the form on success; a form opened by another form's **Create …** button (a reference field with nothing to pick) goes back to that form on `run` (with the new item picked) or `cancel` | | `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input; in the viewport a click selects what is under it (Shift adds, Ctrl/Cmd adds or removes, a click on nothing clears the selection), and a click on a results plot also probes the nearest node of the face under it (`solve.probe`; `ui.inspect` shows the values) | | `ui.move` | `x`, `y` | | | `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` (1–200) | press, move, release: in the viewport a left drag orbits (with box selection on, it selects the visible nodes, element faces or elements in the box), a right or middle drag (or Shift + left) pans |