forked from 1b-agent/fieldcraft-upstream
UI: form problems name fields by their labels, and OK waits for required fields
Every problem reads with the field's label ("Material: choose a material")
beside its field, once it was edited or OK was tried; a click on the greyed
OK shows them all, and its tooltip lists them. A parameter the command
refuses shows beside its field, by its label, in the form and status bar.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
9dcbb9c9a6
commit
904dc3381b
3 files changed
+318
-37
No files matched your search
@@ -120,6 +120,12 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
});
|
});
|
||||||
let mut invalid = 0;
|
let mut invalid = 0;
|
||||||
let mut create = None;
|
let mut create = None;
|
||||||
|
// What is missing or wrong, by the fields' labels: OK waits for it (in either view).
|
||||||
|
let problems = match (spec.form, params.as_object()) {
|
||||||
|
(Some(fields), Some(obj)) => crate::form::messages(fields, obj),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
let missing = problems.iter().map(|(_, m)| m.as_str()).collect::<Vec<_>>().join("\n");
|
||||||
// The typed fields edit the JSON text's object; text that is not an object stays in the
|
// The typed fields edit the JSON text's object; text that is not an object stays in the
|
||||||
// JSON view until it is.
|
// JSON view until it is.
|
||||||
let object = params.as_object().cloned();
|
let object = params.as_object().cloned();
|
||||||
@@ -134,14 +140,25 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
d.advanced = advanced;
|
d.advanced = advanced;
|
||||||
match (spec.form, object) {
|
match (spec.form, object) {
|
||||||
(Some(fields), Some(mut obj)) if !d.advanced => {
|
(Some(fields), Some(mut obj)) if !d.advanced => {
|
||||||
let cx = crate::form::Context { units, names: &|c| session.names(c), salt: form_id };
|
let refused = d.refused.as_ref().map(|(k, p)| (k.as_str(), p.as_str()));
|
||||||
|
let cx = crate::form::Context {
|
||||||
|
units,
|
||||||
|
names: &|c| session.names(c),
|
||||||
|
salt: form_id,
|
||||||
|
touched: &d.touched,
|
||||||
|
attempted: d.attempted,
|
||||||
|
refused,
|
||||||
|
problems: &problems,
|
||||||
|
};
|
||||||
let out = crate::form::show(ui, t, &cx, fields, &mut obj);
|
let out = crate::form::show(ui, t, &cx, fields, &mut obj);
|
||||||
invalid = out.invalid;
|
invalid = out.invalid;
|
||||||
create = out.create;
|
create = out.create;
|
||||||
if out.changed {
|
if out.changed {
|
||||||
d.text = Value::Object(obj).to_string();
|
d.text = Value::Object(obj).to_string();
|
||||||
d.error = None;
|
d.error = None;
|
||||||
|
d.refused = None;
|
||||||
}
|
}
|
||||||
|
d.touched.extend(out.touched);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
ui.add(egui::TextEdit::multiline(&mut d.text).id(form_id.with("json")).code_editor().desired_rows(6).desired_width(f32::INFINITY));
|
ui.add(egui::TextEdit::multiline(&mut d.text).id(form_id.with("json")).code_editor().desired_rows(6).desired_width(f32::INFINITY));
|
||||||
@@ -149,23 +166,34 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
}
|
}
|
||||||
if let Some(e) = &d.error {
|
if let Some(e) = &d.error {
|
||||||
ui.label(RichText::new(e).color(t.error));
|
ui.label(RichText::new(e).color(t.error));
|
||||||
|
} else if d.attempted && !missing.is_empty() {
|
||||||
|
ui.label(RichText::new(&missing).color(t.error));
|
||||||
}
|
}
|
||||||
ui.collapsing(RichText::new("What it takes").size(12.0).color(t.text_dim), |ui| {
|
ui.collapsing(RichText::new("What it takes").size(12.0).color(t.text_dim), |ui| {
|
||||||
ui.label(RichText::new(spec.params).small().color(t.text_dim));
|
ui.label(RichText::new(spec.params).small().color(t.text_dim));
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
let ready = invalid == 0 && missing.is_empty();
|
||||||
|
let not_ready =
|
||||||
|
|| if invalid > 0 { format!("{missing}\nA field holds something that is not a value").trim().to_string() } else { missing.clone() };
|
||||||
|
let mut attempted = false;
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
cancel |= ui.button("Cancel").clicked();
|
cancel |= ui.button("Cancel").clicked();
|
||||||
run = ui
|
let ok =
|
||||||
.add_enabled(
|
ui.add_enabled(ready, egui::Button::new(RichText::new("OK").color(egui::Color32::WHITE)).fill(t.accent).min_size(vec2(64.0, 0.0)));
|
||||||
invalid == 0,
|
// A click on the greyed OK shows every problem, beside its field.
|
||||||
egui::Button::new(RichText::new("OK").color(egui::Color32::WHITE)).fill(t.accent).min_size(vec2(64.0, 0.0)),
|
attempted = !ready && ok.contains_pointer() && ui.input(|i| i.pointer.primary_clicked());
|
||||||
)
|
run = ok
|
||||||
.on_disabled_hover_text("A field holds something that is not a value")
|
.on_disabled_hover_ui(|ui| {
|
||||||
|
ui.label(not_ready());
|
||||||
|
})
|
||||||
.clicked();
|
.clicked();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if attempted && let Some(d) = app.dialog.as_mut() {
|
||||||
|
d.attempted = true;
|
||||||
|
}
|
||||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) && app.ui.menu.is_none() && !app.ui.palette {
|
if ui.input(|i| i.key_pressed(egui::Key::Escape)) && app.ui.menu.is_none() && !app.ui.palette {
|
||||||
cancel = true;
|
cancel = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+222
-20
@@ -2,7 +2,7 @@
|
|||||||
//! with units, drop-downs of the model's items, groups and one-of variants. The form edits the
|
//! 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.
|
//! command's JSON parameters in place; it never builds anything the command does not take.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use egui::{Id, RichText, Ui};
|
use egui::{Id, RichText, Ui};
|
||||||
use fieldcraft_engine::schema::{self, Collection, Field, Kind, MAX_DEPTH, MAX_ROWS, join};
|
use fieldcraft_engine::schema::{self, Collection, Field, Kind, MAX_DEPTH, MAX_ROWS, join};
|
||||||
@@ -27,6 +27,14 @@ pub struct Context<'a> {
|
|||||||
/// A salt for the widget ids: the open form (its command and the opening), so no widget
|
/// A salt for the widget ids: the open form (its command and the opening), so no widget
|
||||||
/// state is shared with another form.
|
/// state is shared with another form.
|
||||||
pub salt: Id,
|
pub salt: Id,
|
||||||
|
/// The fields the user has edited: their problems show beside them; the others wait.
|
||||||
|
pub touched: &'a BTreeSet<String>,
|
||||||
|
/// OK was tried: every problem shows.
|
||||||
|
pub attempted: bool,
|
||||||
|
/// The command refused a parameter: its path and the problem, shown beside the field.
|
||||||
|
pub refused: Option<(&'a str, &'a str)>,
|
||||||
|
/// What is missing or wrong in the parameters ([`messages`]).
|
||||||
|
pub problems: &'a [(String, String)],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a frame of the form did.
|
/// What a frame of the form did.
|
||||||
@@ -37,11 +45,14 @@ pub struct Outcome {
|
|||||||
pub invalid: usize,
|
pub invalid: usize,
|
||||||
/// A reference field's "Create …" was clicked: the collection to add to and the field's path.
|
/// A reference field's "Create …" was clicked: the collection to add to and the field's path.
|
||||||
pub create: Option<(Collection, String)>,
|
pub create: Option<(Collection, String)>,
|
||||||
|
/// The paths of the fields edited this frame.
|
||||||
|
pub touched: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Form<'a> {
|
struct Form<'a> {
|
||||||
cx: &'a Context<'a>,
|
cx: &'a Context<'a>,
|
||||||
style: Style,
|
style: Style,
|
||||||
|
/// Each field's problem, by path, phrased with its label ([`messages`]).
|
||||||
problems: BTreeMap<String, String>,
|
problems: BTreeMap<String, String>,
|
||||||
names: BTreeMap<&'static str, Vec<String>>,
|
names: BTreeMap<&'static str, Vec<String>>,
|
||||||
out: Outcome,
|
out: Outcome,
|
||||||
@@ -52,8 +63,8 @@ pub fn style(t: &Tokens) -> Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draw `fields` editing `params` (an object).
|
/// Draw `fields` editing `params` (an object).
|
||||||
pub fn show(ui: &mut Ui, t: &Tokens, cx: &Context, fields: &[Field], params: &mut Map<String, Value>) -> Outcome {
|
pub fn show(ui: &mut Ui, t: &Tokens, cx: &Context, fields: &'static [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 problems = cx.problems.iter().cloned().collect();
|
||||||
let mut f = Form { cx, style: style(t), problems, names: BTreeMap::new(), out: Outcome::default() };
|
let mut f = Form { cx, style: style(t), problems, names: BTreeMap::new(), out: Outcome::default() };
|
||||||
ui.spacing_mut().item_spacing.y = 3.0;
|
ui.spacing_mut().item_spacing.y = 3.0;
|
||||||
f.fields(ui, fields, fields, params, "", 0);
|
f.fields(ui, fields, fields, params, "", 0);
|
||||||
@@ -61,6 +72,23 @@ pub fn show(ui: &mut Ui, t: &Tokens, cx: &Context, fields: &[Field], params: &mu
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Form<'_> {
|
impl Form<'_> {
|
||||||
|
/// The problem with the field at `path`, under it: the command's refusal at once, the
|
||||||
|
/// form's own once the field was edited or OK was tried.
|
||||||
|
fn problem_note(&self, ui: &mut Ui, path: &str, label: &str) {
|
||||||
|
let message = match self.cx.refused {
|
||||||
|
Some((key, problem)) if key == path => Some(format!("{label}: {problem}")),
|
||||||
|
_ => self.problems.get(path).filter(|_| self.cx.attempted || self.cx.touched.contains(path)).cloned(),
|
||||||
|
};
|
||||||
|
if let Some(message) = message {
|
||||||
|
fields::note(ui, &self.style, &message, self.style.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn touch(&mut self, path: &str) {
|
||||||
|
self.out.changed = true;
|
||||||
|
self.out.touched.push(path.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
fn id(&self, path: &str) -> Id {
|
fn id(&self, path: &str) -> Id {
|
||||||
Id::new(("fc_form", self.cx.salt, path))
|
Id::new(("fc_form", self.cx.salt, path))
|
||||||
}
|
}
|
||||||
@@ -89,10 +117,12 @@ impl Form<'_> {
|
|||||||
&& f.key.is_empty()
|
&& f.key.is_empty()
|
||||||
{
|
{
|
||||||
let current = obj.get(tag).and_then(Value::as_str).map(str::to_string);
|
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)) {
|
let path = join(prefix, tag);
|
||||||
|
if let Some(t) = self.variant_choice(ui, f, variants, current.as_deref(), &path) {
|
||||||
*obj = schema::switch_variant(f, common, obj, &t);
|
*obj = schema::switch_variant(f, common, obj, &t);
|
||||||
self.out.changed = true;
|
self.touch(&path);
|
||||||
}
|
}
|
||||||
|
self.problem_note(ui, &path, f.label);
|
||||||
if let Some(v) = obj.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
|
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);
|
self.fields(ui, v.fields, common, obj, prefix, depth + 1);
|
||||||
}
|
}
|
||||||
@@ -124,16 +154,14 @@ impl Form<'_> {
|
|||||||
Value::Null => obj.remove(f.key),
|
Value::Null => obj.remove(f.key),
|
||||||
v => obj.insert(f.key.into(), v),
|
v => obj.insert(f.key.into(), v),
|
||||||
};
|
};
|
||||||
self.out.changed = true;
|
self.touch(&path);
|
||||||
}
|
}
|
||||||
if let Kind::Either(options) = f.kind {
|
if let Kind::Either(options) = f.kind {
|
||||||
self.either_value(ui, f, options, obj, &path);
|
self.either_value(ui, f, options, obj, &path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(message) = self.problems.get(&path).cloned() {
|
self.problem_note(ui, &path, f.label);
|
||||||
fields::note(ui, &self.style, &message, self.style.error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A one-row field; returns its new value (`Null` removes it).
|
/// A one-row field; returns its new value (`Null` removes it).
|
||||||
@@ -210,7 +238,7 @@ impl Form<'_> {
|
|||||||
let new = fields::row(ui, &style, "", false, |ui| self.scalar(ui, option, Some(&value), &sub));
|
let new = fields::row(ui, &style, "", false, |ui| self.scalar(ui, option, Some(&value), &sub));
|
||||||
if let Some(new) = new {
|
if let Some(new) = new {
|
||||||
obj.insert(f.key.into(), json!({key: new}));
|
obj.insert(f.key.into(), json!({key: new}));
|
||||||
self.out.changed = true;
|
self.touch(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +267,8 @@ impl Form<'_> {
|
|||||||
self.json_only(ui);
|
self.json_only(ui);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
_ => vec![if nullable { Value::Null } else { json!(0.0) }; labels.len()],
|
// Empty slots show their placeholder (x, y, z), not a 0 nobody typed.
|
||||||
|
_ => vec![Value::Null; labels.len()],
|
||||||
};
|
};
|
||||||
let mut next = current.clone();
|
let mut next = current.clone();
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
@@ -252,10 +281,10 @@ impl Form<'_> {
|
|||||||
for &i in chunk {
|
for &i in chunk {
|
||||||
let hint = labels.get(i).copied().unwrap_or("");
|
let hint = labels.get(i).copied().unwrap_or("");
|
||||||
let slot = current.get(i).cloned().unwrap_or(Value::Null);
|
let slot = current.get(i).cloned().unwrap_or(Value::Null);
|
||||||
let shown = if nullable && slot.is_null() { None } else { Some(&slot) };
|
let shown = (!slot.is_null()).then_some(&slot);
|
||||||
if let Some(v) = self.number(ui, id.with(i), shown, hint, false, width) {
|
if let Some(v) = self.number(ui, id.with(i), shown, hint, false, width) {
|
||||||
if let Some(n) = next.get_mut(i) {
|
if let Some(n) = next.get_mut(i) {
|
||||||
*n = if v.is_null() && !nullable { json!(0.0) } else { v };
|
*n = v;
|
||||||
}
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -267,7 +296,8 @@ impl Form<'_> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
// A vector left empty is not written until edited (a required one shows as missing).
|
// A vector left empty is not written until edited (a required one shows as missing).
|
||||||
changed.then_some(Value::Array(next))
|
// The slots left empty of a vector that takes no blanks are 0.
|
||||||
|
changed.then(|| Value::Array(next.into_iter().map(|x| if x.is_null() && !nullable { json!(0.0) } else { x }).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A drop-down of the collection's items; never free text. With none, the drop-down is
|
/// A drop-down of the collection's items; never free text. With none, the drop-down is
|
||||||
@@ -373,7 +403,7 @@ impl Form<'_> {
|
|||||||
} else {
|
} else {
|
||||||
obj.remove(f.key);
|
obj.remove(f.key);
|
||||||
}
|
}
|
||||||
self.out.changed = true;
|
self.touch(path);
|
||||||
}
|
}
|
||||||
if !on {
|
if !on {
|
||||||
return;
|
return;
|
||||||
@@ -393,10 +423,12 @@ impl Form<'_> {
|
|||||||
};
|
};
|
||||||
let current = child.get(tag).and_then(Value::as_str).map(str::to_string);
|
let current = child.get(tag).and_then(Value::as_str).map(str::to_string);
|
||||||
let row = Field { label: "Type", required: true, ..*f };
|
let row = Field { label: "Type", required: true, ..*f };
|
||||||
if let Some(t) = self.variant_choice(ui, &row, variants, current.as_deref(), &join(path, tag)) {
|
let tag_path = join(path, tag);
|
||||||
|
if let Some(t) = self.variant_choice(ui, &row, variants, current.as_deref(), &tag_path) {
|
||||||
*child = schema::switch_variant(f, &[], child, &t);
|
*child = schema::switch_variant(f, &[], child, &t);
|
||||||
self.out.changed = true;
|
self.touch(&tag_path);
|
||||||
}
|
}
|
||||||
|
self.problem_note(ui, &tag_path, row.label);
|
||||||
if let Some(v) = child.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
|
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);
|
self.fields(ui, v.fields, v.fields, child, path, depth + 1);
|
||||||
}
|
}
|
||||||
@@ -427,15 +459,95 @@ impl Form<'_> {
|
|||||||
}
|
}
|
||||||
if let Some(i) = remove {
|
if let Some(i) = remove {
|
||||||
list.remove(i);
|
list.remove(i);
|
||||||
self.out.changed = true;
|
self.touch(path);
|
||||||
}
|
}
|
||||||
if list.len() < max && ui.small_button("+ Add").clicked() {
|
if list.len() < max && ui.small_button("+ Add").clicked() {
|
||||||
list.push(schema::prefill(fields, &json!({})));
|
list.push(schema::prefill(fields, &json!({})));
|
||||||
self.out.changed = true;
|
self.touch(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What is missing or wrong in `params`, in the form's order: (field path, message), each
|
||||||
|
/// phrased with the field's label as the form shows it ("Material: choose a material"), never
|
||||||
|
/// its JSON key.
|
||||||
|
pub fn messages(fields: &'static [Field], params: &Map<String, Value>) -> Vec<(String, String)> {
|
||||||
|
schema::object_problems(fields, params)
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| {
|
||||||
|
let message = field_at(fields, &p.path).map_or_else(|| format!("{}: {}", p.path, p.message), |f| phrase(f, &p.message));
|
||||||
|
(p.path, message)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The field at `path` (`elastic/young`, `rows/0/name`). A variant's tag gives its
|
||||||
|
/// [`Kind::Variants`] field; an [`Kind::Either`] option's value gives the option.
|
||||||
|
pub fn field_at(fields: &'static [Field], path: &str) -> Option<&'static Field> {
|
||||||
|
let mut segments = path.split('/');
|
||||||
|
let mut field = find(fields, segments.next()?)?;
|
||||||
|
for (depth, segment) in segments.enumerate() {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
field = match field.kind {
|
||||||
|
Kind::Rows { .. } if segment.parse::<usize>().is_ok() => field,
|
||||||
|
Kind::Group(inner) | Kind::Rows { fields: inner, .. } => find(inner, segment)?,
|
||||||
|
Kind::Variants { tag, .. } if tag == segment => field,
|
||||||
|
Kind::Variants { variants, .. } => variants.iter().find_map(|v| find(v.fields, segment))?,
|
||||||
|
Kind::Either(options) => options.iter().find(|o| o.key == segment)?,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Some(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The field of `fields` with key `key`, looking into flattened variants.
|
||||||
|
fn find(fields: &'static [Field], key: &str) -> Option<&'static Field> {
|
||||||
|
fields.iter().find_map(|f| match f.kind {
|
||||||
|
Kind::Variants { tag, variants } if f.key.is_empty() => {
|
||||||
|
if tag == key {
|
||||||
|
Some(f)
|
||||||
|
} else {
|
||||||
|
variants.iter().find_map(|v| find(v.fields, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (f.key == key).then_some(f),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A schema problem ([`schema::Problem::message`]) of field `f`, as the form says it.
|
||||||
|
fn phrase(f: &Field, problem: &str) -> String {
|
||||||
|
let required = problem == "required";
|
||||||
|
let text = match f.kind {
|
||||||
|
Kind::Ref(c) if required => format!("choose {}", article(c.label())),
|
||||||
|
Kind::RefList(c) if required => format!("choose at least one {}", c.label()),
|
||||||
|
Kind::Either(options) => format!("choose {}", options.iter().map(|o| article(&lower(o.label))).collect::<Vec<_>>().join(" or ")),
|
||||||
|
Kind::Choice(options) => format!("choose one of {}", options.iter().map(|(_, l)| *l).collect::<Vec<_>>().join(", ")),
|
||||||
|
Kind::Variants { .. } => format!("choose {}", article(&lower(f.label))),
|
||||||
|
Kind::Path { .. } if required => "choose a file".into(),
|
||||||
|
Kind::Text if required && f.key.ends_with("name") => "enter a name".into(),
|
||||||
|
_ if required => "enter a value".into(),
|
||||||
|
_ => format!("must be {problem}"),
|
||||||
|
};
|
||||||
|
format!("{}: {text}", f.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "a material", "an element".
|
||||||
|
fn article(noun: &str) -> String {
|
||||||
|
let an = noun.starts_with(['a', 'e', 'i', 'o', 'u']);
|
||||||
|
format!("{} {noun}", if an { "an" } else { "a" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A label lower-cased, but not its acronyms ("Reference point" → "reference point", "RBE2" stays).
|
||||||
|
fn lower(label: &str) -> String {
|
||||||
|
let mut chars = label.chars();
|
||||||
|
match (chars.next(), chars.next()) {
|
||||||
|
(Some(c), Some(d)) if d.is_lowercase() => c.to_lowercase().chain(label.chars().skip(1)).collect(),
|
||||||
|
_ => label.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A collection's label in the plural: "materials", "point masses", "bodies".
|
/// A collection's label in the plural: "materials", "point masses", "bodies".
|
||||||
fn plural(label: &str) -> String {
|
fn plural(label: &str) -> String {
|
||||||
if let Some(stem) = label.strip_suffix('y') {
|
if let Some(stem) = label.strip_suffix('y') {
|
||||||
@@ -512,7 +624,16 @@ mod tests {
|
|||||||
let width = crate::context::WIDTH - 16.0;
|
let width = crate::context::WIDTH - 16.0;
|
||||||
let mut used = 0.0;
|
let mut used = 0.0;
|
||||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||||
let cx = Context { units: UnitSystem::MmTonneS, names, salt: Id::new(c.id) };
|
let touched = BTreeSet::new();
|
||||||
|
let cx = Context {
|
||||||
|
units: UnitSystem::MmTonneS,
|
||||||
|
names,
|
||||||
|
salt: Id::new(c.id),
|
||||||
|
touched: &touched,
|
||||||
|
attempted: true,
|
||||||
|
refused: None,
|
||||||
|
problems: &messages(form, ¶ms),
|
||||||
|
};
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.set_max_width(width);
|
ui.set_max_width(width);
|
||||||
outcome = show(ui, &Tokens::get(false), &cx, form, &mut params);
|
outcome = show(ui, &Tokens::get(false), &cx, form, &mut params);
|
||||||
@@ -527,4 +648,85 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In every form, each required field left empty is a problem, so OK waits, and the message
|
||||||
|
/// names the field by its label, never by a JSON key.
|
||||||
|
#[test]
|
||||||
|
fn every_required_field_left_empty_is_named_by_its_label() {
|
||||||
|
let mut checked = 0;
|
||||||
|
for c in fieldcraft_engine::commands() {
|
||||||
|
let Some(form) = c.form else { continue };
|
||||||
|
let full = schema::prefill(form, &json!({}));
|
||||||
|
let Some(full) = full.as_object() else { continue };
|
||||||
|
for (path, message) in messages(form, full) {
|
||||||
|
assert!(!message.contains('`'), "{}: {path}: {message}", c.id);
|
||||||
|
}
|
||||||
|
for f in form.iter().filter(|f| f.required) {
|
||||||
|
let key = match f.kind {
|
||||||
|
Kind::Variants { tag, .. } if f.key.is_empty() => tag,
|
||||||
|
_ => f.key,
|
||||||
|
};
|
||||||
|
let mut params = full.clone();
|
||||||
|
params.remove(key);
|
||||||
|
let found: BTreeMap<String, String> = messages(form, ¶ms).into_iter().collect();
|
||||||
|
let message = found.get(key).unwrap_or_else(|| panic!("{}: `{key}` empty is no problem: {found:?}", c.id));
|
||||||
|
assert!(message.starts_with(&format!("{}: ", f.label)), "{}: {message}", c.id);
|
||||||
|
for m in found.values() {
|
||||||
|
assert!(!m.contains('`'), "{}: {m}", c.id);
|
||||||
|
}
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(checked > 50, "{checked}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn messages_read_as_the_form_shows_them() {
|
||||||
|
let plasticity = fieldcraft_engine::find_command("model.material.plasticity").and_then(|c| c.form).unwrap();
|
||||||
|
let m = messages(plasticity, &Map::new());
|
||||||
|
assert_eq!(m.first(), Some(&("name".to_string(), "Material: choose a material".to_string())), "{m:?}");
|
||||||
|
let load = fieldcraft_engine::find_command("model.load.create").and_then(|c| c.form).unwrap();
|
||||||
|
let m: Vec<String> = messages(load, &json!({"type": "force"}).as_object().cloned().unwrap_or_default()).into_iter().map(|(_, m)| m).collect();
|
||||||
|
assert_eq!(m, ["Name: enter a name", "Target: choose a set or a reference point", "Force: enter a value"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A problem waits until its field is edited or OK is tried; a refused parameter shows at once.
|
||||||
|
#[test]
|
||||||
|
fn problems_show_once_touched_or_attempted() {
|
||||||
|
let form = fieldcraft_engine::find_command("model.material.plasticity").and_then(|c| c.form).unwrap();
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let draw = |touched: &BTreeSet<String>, attempted: bool, refused: Option<(&str, &str)>| {
|
||||||
|
let mut shown = String::new();
|
||||||
|
let mut params = Map::new();
|
||||||
|
let names = |_: Collection| Vec::new();
|
||||||
|
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||||
|
let cx = Context {
|
||||||
|
units: UnitSystem::MmTonneS,
|
||||||
|
names: &names,
|
||||||
|
salt: Id::new("t"),
|
||||||
|
touched,
|
||||||
|
attempted,
|
||||||
|
refused,
|
||||||
|
problems: &messages(form, ¶ms),
|
||||||
|
};
|
||||||
|
show(ui, &Tokens::get(false), &cx, form, &mut params);
|
||||||
|
});
|
||||||
|
out.textures_delta.clear();
|
||||||
|
// The text drawn this frame.
|
||||||
|
for clipped in &out.shapes {
|
||||||
|
if let egui::Shape::Text(t) = &clipped.shape {
|
||||||
|
shown.push_str(t.galley.text());
|
||||||
|
shown.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shown
|
||||||
|
};
|
||||||
|
let none = BTreeSet::new();
|
||||||
|
assert!(!draw(&none, false, None).contains("choose a material"));
|
||||||
|
assert!(draw(&none, true, None).contains("Material: choose a material"));
|
||||||
|
let touched: BTreeSet<String> = ["name".to_string()].into();
|
||||||
|
assert!(draw(&touched, false, None).contains("Material: choose a material"));
|
||||||
|
let refused = draw(&none, false, Some(("name", "names no material")));
|
||||||
|
assert!(refused.contains("Material: names no material") && !refused.contains("choose a material"), "{refused}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+61
-10
@@ -50,8 +50,8 @@ use std::hash::{Hash, Hasher};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
|
|
||||||
use fieldcraft_engine::Session;
|
|
||||||
use fieldcraft_engine::catalogue::{self, Tab, Workspace};
|
use fieldcraft_engine::catalogue::{self, Tab, Workspace};
|
||||||
|
use fieldcraft_engine::{EngineError, Session};
|
||||||
use fieldcraft_render::glyphs::Glyphs;
|
use fieldcraft_render::glyphs::Glyphs;
|
||||||
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView};
|
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -158,6 +158,12 @@ pub struct ParamsDialog {
|
|||||||
/// The parameters as JSON text.
|
/// The parameters as JSON text.
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
/// The command refused one parameter: its path and the problem, shown beside its field.
|
||||||
|
pub refused: Option<(String, String)>,
|
||||||
|
/// The paths of the fields edited: their problems show; the others wait for OK.
|
||||||
|
pub touched: std::collections::BTreeSet<String>,
|
||||||
|
/// OK was tried with something missing: every problem shows.
|
||||||
|
pub attempted: bool,
|
||||||
/// The JSON text is shown instead of the typed fields.
|
/// The JSON text is shown instead of the typed fields.
|
||||||
pub advanced: bool,
|
pub advanced: bool,
|
||||||
/// Which opening of a form this is (counts up): a form opened over another one, even of the
|
/// Which opening of a form this is (counts up): a form opened over another one, even of the
|
||||||
@@ -167,6 +173,12 @@ pub struct ParamsDialog {
|
|||||||
pub back: Option<Box<Back>>,
|
pub back: Option<Box<Back>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A command that failed: its message, and the parameter it refused (path, problem).
|
||||||
|
struct Failure {
|
||||||
|
message: String,
|
||||||
|
refused: Option<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The form a "Create …" button left, and where the item made goes in it.
|
/// The form a "Create …" button left, and where the item made goes in it.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Back {
|
pub struct Back {
|
||||||
@@ -453,10 +465,25 @@ impl ModelApp {
|
|||||||
/// Run a command with JSON parameters: what every panel, the control channel and scripts
|
/// Run a command with JSON parameters: what every panel, the control channel and scripts
|
||||||
/// do. The result or the error goes to the status bar.
|
/// do. The result or the error goes to the status bar.
|
||||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||||
self.ensure_idle()?;
|
self.run_form(id, params, None).map_err(|f| f.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::run`] from a form with `fields`: a refused parameter is named by its field's label
|
||||||
|
/// (the engine's message names its key, which agents need).
|
||||||
|
fn run_form(&mut self, id: &str, params: Value, fields: Option<&'static [fieldcraft_engine::schema::Field]>) -> Result<Value, Failure> {
|
||||||
|
self.ensure_idle().map_err(|message| Failure { message, refused: None })?;
|
||||||
let revision = self.session.revision();
|
let revision = self.session.revision();
|
||||||
let label = command_label(id);
|
let label = command_label(id);
|
||||||
let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
|
let r = self.session.execute(id, ¶ms).map_err(|e| match e {
|
||||||
|
EngineError::Param { key, problem } => {
|
||||||
|
let message = match fields.and_then(|f| form::field_at(f, &key)) {
|
||||||
|
Some(f) => format!("{}: {problem}", f.label),
|
||||||
|
None => format!("`{key}` {problem}"),
|
||||||
|
};
|
||||||
|
Failure { message, refused: Some((key, problem)) }
|
||||||
|
}
|
||||||
|
e => Failure { message: e.to_string(), refused: None },
|
||||||
|
});
|
||||||
let changed = self.session.revision() != revision;
|
let changed = self.session.revision() != revision;
|
||||||
if changed {
|
if changed {
|
||||||
self.model_changed();
|
self.model_changed();
|
||||||
@@ -478,7 +505,7 @@ impl ModelApp {
|
|||||||
}
|
}
|
||||||
self.set_status(line, false)
|
self.set_status(line, false)
|
||||||
}
|
}
|
||||||
Err(e) => self.set_status(format!("{label}: {e}"), true),
|
Err(e) => self.set_status(format!("{label}: {}", e.message), true),
|
||||||
}
|
}
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
@@ -579,6 +606,9 @@ impl ModelApp {
|
|||||||
command: spec.id,
|
command: spec.id,
|
||||||
text: params.to_string(),
|
text: params.to_string(),
|
||||||
error: None,
|
error: None,
|
||||||
|
refused: None,
|
||||||
|
touched: Default::default(),
|
||||||
|
attempted: false,
|
||||||
advanced: spec.form.is_none(),
|
advanced: spec.form.is_none(),
|
||||||
opening: self.forms_opened,
|
opening: self.forms_opened,
|
||||||
back: None,
|
back: None,
|
||||||
@@ -626,16 +656,20 @@ impl ModelApp {
|
|||||||
/// Run the open parameter form; it closes when the command succeeds.
|
/// Run the open parameter form; it closes when the command succeeds.
|
||||||
pub fn run_dialog(&mut self) -> Result<Value, String> {
|
pub fn run_dialog(&mut self) -> Result<Value, String> {
|
||||||
let (command, params) = self.dialog_params()?;
|
let (command, params) = self.dialog_params()?;
|
||||||
let r = self.run(command, params);
|
let fields = fieldcraft_engine::find_command(command).and_then(|c| c.form);
|
||||||
match &r {
|
match self.run_form(command, params, fields) {
|
||||||
Ok(_) => self.dialog_done(),
|
Ok(v) => {
|
||||||
Err(e) => {
|
self.dialog_done();
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
Err(Failure { message, refused }) => {
|
||||||
if let Some(open) = self.dialog.as_mut() {
|
if let Some(open) = self.dialog.as_mut() {
|
||||||
open.error = Some(e.clone());
|
open.error = Some(message.clone());
|
||||||
|
open.refused = refused;
|
||||||
}
|
}
|
||||||
|
Err(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The open form's command and parameters; parameters that are not JSON are the form's error.
|
/// The open form's command and parameters; parameters that are not JSON are the form's error.
|
||||||
@@ -1445,6 +1479,23 @@ mod tests {
|
|||||||
assert!(app.start("no.such").is_err());
|
assert!(app.start("no.such").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A parameter the command refuses is named by its field's label in the form and the status
|
||||||
|
/// bar; the command itself (agents) still names the key.
|
||||||
|
#[test]
|
||||||
|
fn a_refused_parameter_is_named_by_its_label() {
|
||||||
|
let mut app = ModelApp::new(Session::default());
|
||||||
|
app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}})).unwrap();
|
||||||
|
app.start("model.material.plasticity").unwrap();
|
||||||
|
app.dialog.as_mut().unwrap().text = r#"{"name": 7}"#.into();
|
||||||
|
let e = app.run_dialog().unwrap_err();
|
||||||
|
assert_eq!(e, "Material: must be a name");
|
||||||
|
let d = app.dialog.as_ref().unwrap();
|
||||||
|
assert_eq!(d.refused, Some(("name".to_string(), "must be a name".to_string())));
|
||||||
|
assert!(app.status.as_ref().is_some_and(|s| s.text == "Plasticity: Material: must be a name"));
|
||||||
|
let raw = app.session.execute("model.material.plasticity", &json!({"name": 7})).unwrap_err();
|
||||||
|
assert_eq!(raw.to_string(), "`name` must be a name");
|
||||||
|
}
|
||||||
|
|
||||||
/// A required reference with one candidate starts picked (the browser's selection wins); with
|
/// 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
|
/// 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).
|
/// item picked (or as it was, when that command is cancelled).
|
||||||
|
|||||||
Reference in new issue
Block a user