From 6bfa0b410a599d3b6189c0766bde56a8d6ac5aa2 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:27:14 -0400 Subject: [PATCH] =?UTF-8?q?UI:=20a=20reference=20field=20is=20always=20a?= =?UTF-8?q?=20drop-down;=20with=20nothing=20to=20pick=20it=20offers=20Crea?= =?UTF-8?q?te=20=E2=80=A6,=20and=20one=20candidate=20starts=20picked=20(#2?= =?UTF-8?q?15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reference field (a material, set, step…) with no items was a free-text box; it is now a greyed drop-down ("No materials yet") with a button that opens the command making one (the collection's requirement provider). That form comes back to this one on OK, with the new item picked, or as it was on Cancel. A required reference with one candidate, or with the browser's selection of its kind, starts picked when the form opens, so the fields and the JSON view agree. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/browser.rs | 17 ++++++ crates/ui-egui/src/context.rs | 6 +- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/form.rs | 106 +++++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 112 +++++++++++++++++++++++++++++++++- docs/control-protocol.md | 2 +- 6 files changed, 224 insertions(+), 21 deletions(-) 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 |